import { normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark, normalizeBrandURL, normalizeFooterLinks, normalizeKeywords, type BrandLogoFit, type BrandLogoSize, type BrandMark, type BrandSlot, type FooterLink, type FooterLinksAlign, } from "./brand"; import { DEFAULT_LEVEL_DEFS, LEVEL_COLOR_KEYS, MAX_LEVEL_COUNT, type LevelDef } from "./levels"; import { normalizeUrlStyle, type UrlStyle } from "./urlStyle"; import { DEFAULT_MOBILE_TABS, normalizeMobileTabs, type MobileTabKey } from "./mobileTabs"; import { DEFAULT_HEADER_NAV, normalizeHeaderNav, type HeaderNavItem } from "./headerNav"; import { CSRF_COOKIE, hasAuthCookieHint } from "./cookies"; import { publishPublicMetaSnapshot } from "./publicMetaSnapshot"; import { emitForceLogout } from "./userEvents"; export type { FooterLink, FooterLinksAlign }; // API 基础配置 // 注意:客户端请求使用相对路径 /api/*,走 Next.js rewrite 代理到后端, // 这样浏览器视为同源,Cookie 自动携带,无需处理 CORS。 // SSR/middleware 直连后端:优先 BACKEND_URL;生产禁止静默回落 localhost。 const API_BASE = (() => { const fromEnv = process.env.BACKEND_URL || process.env.NEXT_PUBLIC_API_URL || ""; if (fromEnv) return fromEnv; if (process.env.NODE_ENV === "production") { if (typeof window === "undefined") { throw new Error("生产环境必须设置 BACKEND_URL(SSR 直连后端)"); } return ""; } return "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 Badge { id: number; name: string; /** lucide 图标名(前端白名单渲染,未知回落 Medal) */ icon: string; /** CSS token 色板键(gold/accent/ok/danger/info/purple/teal) */ color: string; created_at: string; updated_at: string; } /** 徽章授予记录(badge 为嵌套定义,后端 Preload 填充) */ export interface UserBadgeView { id: number; badge_id: number; user_id: number; badge: Badge; awarded_by: number; awarded_at: string; } export interface User { id: number; username: string; nickname: string; avatar: string; role: string; // 板块管理员被授权的板块(仅 /api/me 返回;前端据此渲染审核入口/按钮) board_ids?: number[]; // 站点消息管理(站长授予;站长/超管固有监管不必依赖此字段) can_manage_messages?: boolean; // 生效权限码(角色默认 ∪ 账号级渠道授予;仅 /api/me 返回,用于后台入口显隐) perms?: string[]; // 作为群管理员的群数量(/me;用于后台消息入口) chat_admin_room_count?: number; // 仅 /api/me 与 /api/profile 响应中返回(用户自身敏感信息) email?: string; signature?: string; // 用户等级(后端按累计获得积分计算;1 起)与累计获得积分 level?: number; total_points?: number; // 管理员颁布的徽章(/me、用户资料页全量;作者点处仅展示前 2 枚) badges?: UserBadgeView[]; } // /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; post_policy?: string; visible?: boolean; } // 管理端板块:列表附带已发布帖子数 export interface AdminBoard { id: number; name: string; description: string; icon: string; color_index: number; sort_order: number; post_policy: string; visible: boolean; post_count?: number; } export interface AdminBoardModerator { id: number; username: string; nickname: string; avatar: string; email: string; banned: boolean; } export type AdminBoardInput = { name: string; description?: string; icon?: string; color_index?: number | null; sort_order?: number | null; post_policy?: string; visible?: boolean | null; }; export const BOARD_POST_POLICY = { ALL: "all", STAFF: "staff", } as const; // 首页聚合接口 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" | "teal" | "cyan" | "pink" | "amber" | "indigo" | "rose"; // 右栏站点公告摘要(标签/日期/标题) export interface AnnouncementSummary { id: number; title: string; tag: string; tag_color: AnnouncementColor; created_at: string; pinned?: boolean; } // 公告详情/后台管理完整对象 export interface Announcement { id: number; title: string; content: string; tag: string; tag_color: AnnouncementColor; published: boolean; pinned: boolean; created_at: string; updated_at: string; } export interface SidebarPageItem { id: number; slug: string; title: string; excerpt: string; } /** 公开单页目录(/about 全部页) */ export interface SitePageSummary { id: number; slug: string; title: string; excerpt: string; created_at: string; updated_at: string; } export interface SitePage { id: number; slug: string; title: string; content: string; excerpt: string; published: boolean; show_in_sidebar: boolean; sort_order: number; allow_comments: boolean; created_at: string; updated_at: string; deleted?: boolean; // 软删标记(仅管理端列表) } export interface SitePageInput { slug: string; title: string; content: string; excerpt?: string; published: boolean; show_in_sidebar: boolean; sort_order?: number; } /** 书库文件(epub/pdf/html 等;StoredName 不出后端) */ export interface LibraryFileItem { id: number; doc_id: number; user_id: number; name: string; ext: string; mime: string; size: number; download_count: number; sort_order: number; created_at: string; } /** 书库章节(两级树:parent_id 空=章,非空=节;正文 markdown) */ export interface LibrarySectionItem { id: number; doc_id: number; parent_id: number | null; title: string; content: string; sort_order: number; created_at: string; updated_at: string; } /** 书库条目创建者摘要(已注销用户时接口省略该字段) */ export interface LibraryCreator { id: number; username: string; nickname: string; avatar: string; } /** 书库条目(详情/管理端,含文件与章节) */ export interface LibraryDoc { id: number; slug: string; title: string; author: string; creator_id: number; creator?: LibraryCreator | null; description: string; cover_url: string; cover_width: number; cover_height: number; published: boolean; sort_order: number; entries_auto?: boolean; // 全章节条目卡模式(章节内容免写 [entries] 壳) created_at: string; updated_at: string; deleted?: boolean; // 软删标记(仅管理端列表) files: LibraryFileItem[]; sections: LibrarySectionItem[]; } /** 书库公开目录条目(description 为 markdown 原文,卡片内按纯文本截断展示) */ export interface LibraryDocListItem { id: number; slug: string; title: string; author: string; description: string; creator?: LibraryCreator | null; cover_url: string; sort_order: number; file_count: number; files: LibraryFileItem[]; created_at: string; updated_at: string; } export interface LibraryInput { slug: string; title: string; author?: string; description?: string; cover_url?: string; cover_width?: number; cover_height?: number; published: boolean; sort_order?: number; entries_auto?: boolean; } 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[]; announcements_total?: number; sidebar_pages?: SidebarPageItem[]; new_users: NewUser[]; checkin?: CheckinStatus | null; ads?: PublicAdItem[]; ads_panel_title?: string; ads_enabled?: boolean; sponsors?: PublicSponsorItem[]; sponsors_panel_title?: string; sponsors_enabled?: boolean; } export interface PublicAdItem { id: number; kind: "image" | "text" | string; link_url: string; image_url?: string; title?: string; text_color?: string; bg_color?: string; ends_at?: string | null; } export interface PublicSponsorItem { id: string; name: string; logo_url: string; link_url?: string; hover_text?: string; } /** 板块右栏:管理员公开资料(含等级与颁发的徽章) */ export interface BoardModerator { id: number; username: string; nickname: string; avatar: string; level?: number; badges?: UserBadgeView[]; } /** 板块右栏:板级统计 */ 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; /** 管理员手动锁定:普通用户不可编辑/回复(staff 豁免) */ locked: boolean; /** 最后一条已发布评论时间;null=无回复(旧帖判定回落 created_at) */ last_reply_at?: string | null; /** 旧帖回复确认提示(详情接口按查看者计算;存在时提交回复前需用户确认) */ necro_reply?: { after_hours: number; penalty: number } | null; status: string; like_count: number; view_count: number; comment_count: number; liked: boolean; /** 详情接口返回:当前查看者是否已收藏 */ favorited?: boolean; /** 详情接口返回:是否存在编辑历史快照(列表接口无此字段) */ edited?: 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; /** false = 楼中楼回复,锚点需 #comment-{floor}-r{id} */ is_root: boolean; 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; solved_at?: string; 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; ends_at?: string; closed_at?: string; 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; } export interface LotteryPrize { name: string; count: number; } export interface LotteryEntrant { id: number; nickname: string; username: string; avatar: string; /** 中奖楼层评论 ID(该用户最早主评,每人仅一条) */ comment_id?: number; floor?: number; prize_name?: string; } export interface LotteryState { slots: number; prizes: LotteryPrize[]; drawn: boolean; closed: boolean; ends_at?: string; entry_count: number; eligible?: boolean; /** 新号入池冷静期小时数(与发帖冷静期同源,0=关闭) */ cooldown_hours?: number; can_draw: boolean; can_edit_prizes?: 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; // 用户等级(后端按累计获得积分计算)与累计获得积分 level?: number; total_points?: number; // 徽章墙(全量,含授予时间) badges?: UserBadgeView[]; /** 访客(登录态)与该用户的关注关系;本人访问恒 false */ is_following?: boolean; /** 仅本人访问时下发的邮箱明文(账号信息展示与资料表单回填) */ email?: string; }; stats: { post_count: number; comment_count: number; points: number; streak: number; favorite_count: number; following_count: number; }; posts: PostListItem[]; posts_total: number; page: number; size: number; } export interface UserCommentItem { id: number; post_id: number; post_title: string; content: string; /** 所属帖内相对楼层号(0 = 评论已不可见) */ floor: number; /** 主楼/楼中楼:楼中楼锚点需 #comment-{floor}-r{id} */ is_root: boolean; created_at: string; } export interface UserCommentsResponse { comments: UserCommentItem[]; total: number; page: number; size: number; } export interface UserFavoritesResponse { posts: PostListItem[]; total: number; page: number; size: number; } export interface UserFollowingItem { id: number; username: string; nickname: string; avatar: string; level?: number; signature?: string; } export interface UserFollowingResponse { users: UserFollowingItem[]; 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" | "badge" | "necro_reply" | "follow"; post_id: number; comment_id: number; room_id?: number; message_id?: number; content: string; is_read: boolean; created_at: string; /** 关联评论在所属帖内的相对楼层号(0/缺省 = 评论已彻底删除) */ comment_floor?: number; /** 关联评论是否主楼;楼中楼锚点需 #comment-{floor}-r{comment_id} */ comment_is_root?: boolean; 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: ReturnType | null = null; // 被封禁通知去重:同一登录生命周期内只强制下线/弹一次, // 重新登录成功(apiLogin)后复位,使"解封后再次被封"仍能再次提示 let bannedNotified = false; // 登录态被剔除通知去重(安全设置剔除设备、会话数超上限被淘汰等)。 // sessionSeen 标记本标签页曾处于登录态:游客触发 401 不应弹"登录已失效" let sessionEndedNotified = false; let sessionSeen = false; // 曾登录、经探测确认已游客化 → 派发 session_replaced 强制下线事件 function notifySessionEnded(): void { if (sessionEndedNotified || !sessionSeen) return; sessionEndedNotified = true; sessionSeen = false; emitForceLogout("session_replaced"); } // 识别响应体中的封禁 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 由浏览器自动携带)。 // transient=true 表示网络异常或后端暂不可用(含代理 5xx):此时无法证明登录态 // 失效,调用方不应据此弹"登录已失效"——dev 后端 go run 重启窗口内尤其常见 export async function apiRefresh(): Promise<{ ok: boolean; banned: boolean; transient: boolean }> { try { const res = await fetch("/api/auth/refresh", { method: "POST", credentials: "include", headers: clientHeaders(), }); if (res.ok) return { ok: true, banned: false, transient: false }; if (res.status >= 500) return { ok: false, banned: false, transient: true }; if (res.status === 403) { const data = await res.json().catch(() => ({} as { code?: string })); if (data.code === "account_banned") return { ok: false, banned: true, transient: false }; } return { ok: false, banned: false, transient: false }; } catch { return { ok: false, banned: false, transient: true }; } } // 带自动续期的 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"); } else if (!result.banned && !result.transient) { // refresh 明确失败且非封禁:登录态已失效(被顶/被剔除/过期), // 曾登录的标签页统一告知,避免界面仍显示已登录。 // 瞬态失败(网络/后端暂不可用)不动登录态,等后续请求自然恢复 notifySessionEnded(); } } 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(); } // 编辑器站内链接:按关键词搜帖子(公开接口;keyword 留空返回最近发布) export async function apiSearchPosts( keyword = "", size = 8 ): Promise<{ posts?: PostListItem[]; error?: string }> { const params = new URLSearchParams({ page: "1", size: String(size), sort: "latest" }); if (keyword) params.set("keyword", keyword); const res = await fetchWithRefresh(`/api/posts?${params}`, { method: "GET", headers: clientHeaders(), }); if (!res.ok) return { error: "搜索帖子失败" }; const data = (await res.json().catch(() => ({}))) as Partial; return { posts: data.posts || [] }; } // 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 type LeaderboardPeriod = "week" | "month" | "all"; /** 榜单维度:全部(综合)| 发帖最多 | 回复最多 | 获赞最多 | 最佳答案 */ export type LeaderboardMetric = "all" | "post" | "comment" | "like" | "answer"; /** 排行榜单条(领奖台/列表通用;level 由后端按累计积分计算) */ export interface LeaderboardEntry { rank: number; id: number; username: string; nickname: string; avatar: string; total_points: number; points: number; level: number; post_count: number; comment_count: number; like_count: number; answer_count: number; score: number; } /** /api/leaderboard 响应 */ export interface LeaderboardResponse { period: LeaderboardPeriod; metric: LeaderboardMetric; entries: LeaderboardEntry[]; } /** 我的排名(rank=0 未上榜;change 正=上升负=下降,has_change=false 表示无上一周期对比) */ export interface LeaderboardMe { period: LeaderboardPeriod; metric: LeaderboardMetric; rank: number; total: number; score: number; change: number; has_change: boolean; } /** SSR 排行榜;失败时抛错,由页面降级为空榜 */ export async function fetchLeaderboard( period: LeaderboardPeriod, metric: LeaderboardMetric, cookieHeader?: string ): Promise { const res = await fetch( `${API_BASE}/api/leaderboard?period=${period}&metric=${metric}`, ssrInit(cookieHeaders(cookieHeader)) ); if (!res.ok) throw new Error("获取排行榜失败"); return res.json(); } /** SSR 我的排名;游客/失败返回 null(不抛错) */ export async function fetchMyRank( period: LeaderboardPeriod, metric: LeaderboardMetric, cookieHeader?: string ): Promise { try { const res = await fetch( `${API_BASE}/api/leaderboard/me?period=${period}&metric=${metric}`, ssrInit(cookieHeaders(cookieHeader)) ); if (!res.ok) return null; const data = (await res.json()) as { me: LeaderboardMe | null }; return data.me ?? null; } catch { return null; } } 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); // publish_without_review 为全站免审,无需过审记录直接发布(缺省/未配置视为 false) export interface PublicSettings { accent: string; trust_reviewed_publish: boolean; publish_without_review: boolean; site_name: string; site_description: string; /** 无 Logo 时页眉字标;空则回退显示名称 */ site_wordmark: string; /** 浏览器标题后缀与页眉副标题;空则不显示 */ site_slogan: string; site_keywords: string[]; logo_light_url: string; logo_dark_url: string; favicon_url: string; /** 页眉形式:image_text 图加文字 / image 纯图 / text 纯文字 */ brand_mark: BrandMark; /** 图槽画幅 */ brand_logo_size: BrandLogoSize; /** 图槽铺法:cover 填充 / contain 适应 / repeat 平铺 / stretch 拉伸 */ brand_logo_fit: BrandLogoFit; footer_links: FooterLink[]; allow_register: boolean; /** 全站是否开放评论;关闭后前台不展示评论能力 */ allow_comments: boolean; /** 评论仅登录可见;缺省 false(游客可读) */ comments_require_login: boolean; /** 全站是否开放私聊/群聊;关闭后前台不展示消息入口 */ allow_messages: 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; /** 帖内 Markdown 外链是否新标签打开;缺省 true */ post_link_new_tab: boolean; /** 是否限制附件扩展名;缺省 true */ attachment_ext_limit: boolean; /** 允许的扩展名(无点);缺省论坛向列表 */ attachment_exts: string[]; /** 附件单文件上限 MB;缺省 20 */ attachment_max_mb: number; /** 每帖附件个数;缺省 10 */ attachment_max_count: number; /** 正文插图上限 MB;缺省 5 */ image_max_mb: number; /** 前台背景图 URL;空=默认画布 */ bg_site_url: string; /** 前台铺放模式 */ bg_site_mode: string; /** 后台背景图 URL;空=默认画布 */ bg_admin_url: string; /** 后台铺放模式 */ bg_admin_mode: string; /** 签到基础分;0=关闭;缺省 5 */ points_checkin_base: number; /** 每满 N 天连续签到触发加成;0=关闭;缺省 7 */ points_streak_every_days: number; /** 连续签到加成分值;缺省 0 */ points_streak_bonus: number; /** 发帖奖励;0=关闭;缺省 0 */ points_post_reward: number; /** 发帖奖励每日上限笔数;0=不限;缺省 0 */ points_post_daily_cap: number; /** 回复奖励;0=关闭;缺省 0 */ points_reply_reward: number; /** 回复奖励每日上限笔数;0=不限;缺省 0 */ points_reply_daily_cap: number; /** 帖子被推荐奖励(每帖仅一次);0=关闭;缺省 0 */ points_recommend_reward: number; /** 帖子可编辑时长(小时);0=不锁定;缺省 0;上限 8760 */ post_edit_lock_hours: number; /** 评论可编辑时长(小时);0=不锁定;缺省 0;上限 8760 */ comment_edit_lock_hours: number; /** 旧帖回复判定时长(小时);0=关闭;缺省 0 */ necro_reply_after_hours: number; /** 旧帖回复扣除积分;0=仅提醒不扣分;缺省 0;上限 100 */ necro_reply_penalty: number; /** 用户等级体系(数量/每级阈值/每级色板 key);等级数值由后端计算 */ levels: LevelDef[]; /** 等级特效动画总开关(流光动画);缺省开 */ levels_fx: boolean; /** 详情页伪静态风格;缺省 default(标准 /post/123) */ url_style: UrlStyle; /** 移动端底栏显示的 tab(固定顺序子集);空/非法回落默认全量 */ mobile_tabs: MobileTabKey[]; /** 桌面端 Header 主导航(数组顺序即展示顺序);空数组=全部隐藏;缺省=内置三项 */ header_nav: HeaderNavItem[]; } /** 与后端 DefaultAttachmentExts 保持同步 */ export const DEFAULT_ATTACHMENT_EXTS: string[] = [ "pdf", "txt", "md", "csv", "json", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "zip", "7z", "rar", "tar", "gz", "tgz", "jpg", "jpeg", "png", "webp", "gif", "mp3", "wav", "ogg", "mp4", "webm", "exe", "msi", "msp", "apk", "dmg", "iso", "deb", "rpm", "dll", "html", "htm", "js", "mjs", "css", "svg", "xml", "bat", "cmd", "ps1", "sh", ]; const DEFAULT_PUBLIC_SETTINGS: PublicSettings = { accent: "", trust_reviewed_publish: true, publish_without_review: false, site_name: "姜十三论坛", site_description: "姜十三论坛 - 技术分享与讨论社区", site_wordmark: "", site_slogan: "", site_keywords: [], logo_light_url: "", logo_dark_url: "", favicon_url: "", brand_mark: "image_text", brand_logo_size: "sq", brand_logo_fit: "contain", footer_links: [], allow_register: true, allow_comments: true, comments_require_login: false, allow_messages: 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, post_link_new_tab: true, attachment_ext_limit: true, attachment_exts: [...DEFAULT_ATTACHMENT_EXTS], attachment_max_mb: 20, attachment_max_count: 10, image_max_mb: 5, bg_site_url: "", bg_site_mode: "cover", bg_admin_url: "", bg_admin_mode: "cover", points_checkin_base: 5, points_streak_every_days: 7, points_streak_bonus: 0, points_post_reward: 0, points_post_daily_cap: 0, points_reply_reward: 0, points_reply_daily_cap: 0, points_recommend_reward: 0, post_edit_lock_hours: 0, comment_edit_lock_hours: 0, necro_reply_after_hours: 0, necro_reply_penalty: 0, levels: [...DEFAULT_LEVEL_DEFS], levels_fx: true, url_style: "default", mobile_tabs: [...DEFAULT_MOBILE_TABS], header_nav: DEFAULT_HEADER_NAV.map((item) => ({ ...item })), }; function normalizeAttachmentExts(raw: unknown): string[] { if (!Array.isArray(raw)) return [...DEFAULT_ATTACHMENT_EXTS]; const seen = new Set(); const out: string[] = []; for (const item of raw) { const e = String(item || "") .trim() .toLowerCase() .replace(/^\./, ""); if (!/^[a-z0-9]{1,16}$/.test(e) || seen.has(e)) continue; seen.add(e); out.push(e); if (out.length >= 80) break; } return out; } function normalizePublicSettings(data: Partial | null | undefined): PublicSettings { const maxMB = typeof data?.attachment_max_mb === "number" && data.attachment_max_mb >= 1 ? Math.round(data.attachment_max_mb) : DEFAULT_PUBLIC_SETTINGS.attachment_max_mb; const maxCount = typeof data?.attachment_max_count === "number" && data.attachment_max_count >= 1 && data.attachment_max_count <= 20 ? Math.round(data.attachment_max_count) : DEFAULT_PUBLIC_SETTINGS.attachment_max_count; const imageMB = typeof data?.image_max_mb === "number" && data.image_max_mb >= 1 ? Math.round(data.image_max_mb) : DEFAULT_PUBLIC_SETTINGS.image_max_mb; return { accent: data?.accent ?? "", trust_reviewed_publish: data?.trust_reviewed_publish !== false, publish_without_review: data?.publish_without_review === true, 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, site_wordmark: String(data?.site_wordmark ?? "").trim(), site_slogan: String(data?.site_slogan ?? "").trim(), site_keywords: normalizeKeywords(data?.site_keywords), logo_light_url: normalizeBrandURL("logo_light", data?.logo_light_url), logo_dark_url: normalizeBrandURL("logo_dark", data?.logo_dark_url), favicon_url: normalizeBrandURL("favicon", data?.favicon_url), brand_mark: normalizeBrandMark(data?.brand_mark), brand_logo_size: normalizeBrandLogoSize(data?.brand_logo_size), brand_logo_fit: normalizeBrandLogoFit(data?.brand_logo_fit), footer_links: normalizeFooterLinks(data?.footer_links), allow_register: data?.allow_register !== false, allow_comments: data?.allow_comments !== false, comments_require_login: data?.comments_require_login === true, allow_messages: data?.allow_messages !== 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, post_link_new_tab: data?.post_link_new_tab !== false, attachment_ext_limit: data?.attachment_ext_limit !== false, attachment_exts: data?.attachment_exts !== undefined ? normalizeAttachmentExts(data.attachment_exts) : [...DEFAULT_ATTACHMENT_EXTS], attachment_max_mb: maxMB, attachment_max_count: maxCount, image_max_mb: imageMB, bg_site_url: typeof data?.bg_site_url === "string" ? data.bg_site_url : "", bg_site_mode: typeof data?.bg_site_mode === "string" ? data.bg_site_mode : "cover", bg_admin_url: typeof data?.bg_admin_url === "string" ? data.bg_admin_url : "", bg_admin_mode: typeof data?.bg_admin_mode === "string" ? data.bg_admin_mode : "cover", ...normalizePointsSettings(data), ...normalizeLockSettings(data), url_style: normalizeUrlStyle(data?.url_style), levels: normalizeLevels(data?.levels), levels_fx: data?.levels_fx !== false, mobile_tabs: normalizeMobileTabs(data?.mobile_tabs), // undefined(字段缺失)回落默认;[] 为空配置(全部隐藏),语义在 normalize 内区分 header_nav: normalizeHeaderNav(data?.header_nav), }; } /** 内容锁定 4 键:小时 0–8760 / 积分 0–100 有效整数,越界/异常回落默认值 */ function normalizeLockSettings( data: Partial | null | undefined, ): Pick< PublicSettings, | "post_edit_lock_hours" | "comment_edit_lock_hours" | "necro_reply_after_hours" | "necro_reply_penalty" > { const hours = (key: keyof PublicSettings): number | undefined => { const v = data?.[key]; return typeof v === "number" && v >= 0 && v <= 8760 ? Math.round(v) : undefined; }; const points = (key: keyof PublicSettings): number | undefined => { const v = data?.[key]; return typeof v === "number" && v >= 0 && v <= 100 ? Math.round(v) : undefined; }; return { post_edit_lock_hours: hours("post_edit_lock_hours") ?? DEFAULT_PUBLIC_SETTINGS.post_edit_lock_hours, comment_edit_lock_hours: hours("comment_edit_lock_hours") ?? DEFAULT_PUBLIC_SETTINGS.comment_edit_lock_hours, necro_reply_after_hours: hours("necro_reply_after_hours") ?? DEFAULT_PUBLIC_SETTINGS.necro_reply_after_hours, necro_reply_penalty: points("necro_reply_penalty") ?? DEFAULT_PUBLIC_SETTINGS.necro_reply_penalty, }; } /** 积分规则 8 键:0–100 有效整数,越界/异常回落默认值 */ function normalizePointsSettings( data: Partial | null | undefined, ): Pick< PublicSettings, | "points_checkin_base" | "points_streak_every_days" | "points_streak_bonus" | "points_post_reward" | "points_post_daily_cap" | "points_reply_reward" | "points_reply_daily_cap" | "points_recommend_reward" > { const num = (key: keyof PublicSettings): number | undefined => { const v = data?.[key]; return typeof v === "number" && v >= 0 && v <= 100 ? Math.round(v) : undefined; }; return { points_checkin_base: num("points_checkin_base") ?? DEFAULT_PUBLIC_SETTINGS.points_checkin_base, points_streak_every_days: num("points_streak_every_days") ?? DEFAULT_PUBLIC_SETTINGS.points_streak_every_days, points_streak_bonus: num("points_streak_bonus") ?? DEFAULT_PUBLIC_SETTINGS.points_streak_bonus, points_post_reward: num("points_post_reward") ?? DEFAULT_PUBLIC_SETTINGS.points_post_reward, points_post_daily_cap: num("points_post_daily_cap") ?? DEFAULT_PUBLIC_SETTINGS.points_post_daily_cap, points_reply_reward: num("points_reply_reward") ?? DEFAULT_PUBLIC_SETTINGS.points_reply_reward, points_reply_daily_cap: num("points_reply_daily_cap") ?? DEFAULT_PUBLIC_SETTINGS.points_reply_daily_cap, points_recommend_reward: num("points_recommend_reward") ?? DEFAULT_PUBLIC_SETTINGS.points_recommend_reward, }; } /** 等级配置:2–12 级、阈值非负且严格递增、首级 0、色板 key 合法;异常回落默认 */ function normalizeLevels(raw: unknown): LevelDef[] { if (!Array.isArray(raw) || raw.length < 2 || raw.length > MAX_LEVEL_COUNT) { return [...DEFAULT_LEVEL_DEFS]; } const out: LevelDef[] = []; let prev = -1; for (const item of raw) { const threshold = typeof item?.threshold === "number" && Number.isFinite(item.threshold) && item.threshold >= 0 ? Math.round(item.threshold) : NaN; const color = typeof item?.color === "string" && LEVEL_COLOR_KEYS.includes(item.color) ? item.color : ""; if (Number.isNaN(threshold) || !color || threshold <= prev) { return [...DEFAULT_LEVEL_DEFS]; } out.push({ threshold, color }); prev = threshold; } if (out[0]?.threshold !== 0) return [...DEFAULT_LEVEL_DEFS]; return out; } // SSR 读取公开站点设置(主题色注入用);后端不可用时降级为默认色,不阻塞渲染 export async function fetchPublicSettings(): Promise { try { const res = await fetch(`${API_BASE}/api/settings`, ssrInit()); if (!res.ok) { const fallback = { ...DEFAULT_PUBLIC_SETTINGS }; publishPublicMetaSnapshot(fallback); return fallback; } const normalized = normalizePublicSettings((await res.json()) as Partial); publishPublicMetaSnapshot(normalized); return normalized; } catch { const fallback = { ...DEFAULT_PUBLIC_SETTINGS }; publishPublicMetaSnapshot(fallback); return fallback; } } /** 公开站点运行态(注册/找回/维护等);SSR 与客户端同源字段 */ export type PublicSiteState = { allow_register: boolean; register_notice: string; verify_email: boolean; password_reset: boolean; site_url: string; }; const DEFAULT_SITE_STATE: PublicSiteState = { allow_register: true, register_notice: "", verify_email: false, password_reset: false, site_url: "", }; export async function fetchSiteState(): Promise { try { const res = await fetch(`${API_BASE}/api/site-state`, ssrInit()); if (!res.ok) return { ...DEFAULT_SITE_STATE }; const data = (await res.json()) as Partial; return { allow_register: data.allow_register !== false, register_notice: typeof data.register_notice === "string" ? data.register_notice : "", verify_email: data.verify_email === true, password_reset: data.password_reset === true, site_url: typeof data.site_url === "string" ? data.site_url : "", }; } catch { return { ...DEFAULT_SITE_STATE }; } } 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, sort: string = "new" ): Promise { const params = new URLSearchParams({ page: String(page), size: "20", sort }); 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 }; } } // cookieHeader:SSR 时必须转发访问者 cookie——后端开启「评论需登录」时, // 匿名请求会拿到空列表(表现为所有人主页回帖都显示暂无评论) export async function fetchUserComments(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}/comments?${params}`, ssrInit(cookieHeaders(cookieHeader))); 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 ?? [] }; } export async function fetchUserFavorites(id: string, page = 1): Promise { const params = new URLSearchParams({ page: String(page), size: "20" }); const res = await fetch(`${API_BASE}/api/users/${id}/favorites?${params}`, ssrInit()); if (res.status === 404) throw new Error("用户不存在"); if (!res.ok) throw new Error("获取收藏失败"); const data = (await res.json()) as UserFavoritesResponse; return { ...data, posts: data.posts ?? [] }; } export async function fetchUserFollowing(id: string, page = 1): Promise { const params = new URLSearchParams({ page: String(page), size: "20" }); const res = await fetch(`${API_BASE}/api/users/${id}/following?${params}`, ssrInit()); if (res.status === 404) throw new Error("用户不存在"); if (!res.ok) throw new Error("获取关注列表失败"); const data = (await res.json()) as UserFollowingResponse; return { ...data, users: data.users ?? [] }; } // 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 { let res = await fetch("/api/me", { credentials: "include", cache: "no-store", }); let data: MeResponse = await res.json(); if (data.user) sessionSeen = true; // /me 以 200 携带封禁标记(Header 挂载静默校正/F5 后的主识别路径) if (data.banned === true && !bannedNotified) { bannedNotified = true; emitForceLogout("banned"); } else if (!data.user && res.ok) { if (sessionSeen && hasAuthCookieHint()) { // access cookie 到达 15min MaxAge 会被浏览器删除,/api/me 只能以游客 // 应答——这不代表会话被剔除(refresh token 仍有效)。先 refresh 恢复 // access 再复判;真被剔除时 refresh 也会明确失败,结论不变 const refreshed = await apiRefresh(); if (refreshed.ok) { res = await fetch("/api/me", { credentials: "include", cache: "no-store" }); data = await res.json(); if (data.user) sessionSeen = true; } else if (refreshed.transient) { // 网络/后端暂不可用:无法证明登录态失效,不下结论 return data; } if (refreshed.banned && !bannedNotified) { bannedNotified = true; emitForceLogout("banned"); return data; } } // 曾登录、经复判仍是游客:登录态在别处被剔除(设备剔除、 // 会话超限淘汰等)。挂载校正/回前台对账/WS 4401 探测都汇聚到这条判定 notifySessionEnded(); } 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; sessionEndedNotified = false; sessionSeen = true; } return data; } export async function apiRegister(username: string, email: string, password: string, code = "") { const res = await fetch("/api/register", { method: "POST", credentials: "include", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ username, email, password, code }), }); return res.json(); } export async function apiLogout() { const res = await fetchWithRefresh("/api/logout", { method: "POST", headers: clientHeaders(), }); // 主动登出后本标签页不再算"曾登录":否则登出后收到的首个 401 会被 // notifySessionEnded 误判为"被顶下线",误弹"登录已失效"弹窗 if (res.ok) { sessionSeen = false; } return res.json(); } export async function apiCreatePost(data: { board_id: number; title: string; content: string; tags?: string; post_type?: string; 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; 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 apiUnlockPostPassword(postId: string, password: string) { const res = await fetchWithRefresh(`/api/posts/${postId}/unlock-password`, { method: "POST", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ password }), }); 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 LedgerParty { id: number; username: string; nickname: string; avatar: string; } export interface PointLedgerItem { id: number; delta: number; balance: number; reason: string; ref_type: string; ref_id: number; note: string; created_at: string; user?: LedgerParty; from?: LedgerParty; to?: LedgerParty; } 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[]; checkin_series?: { date: string; count: number }[]; ledger_by_reason?: { key: string; label: string; count: number }[]; delta_series?: { date: string; granted: number; spent: number }[]; net_delta_7d?: number; } export interface AnalyticsDayCount { date: string; count: number; } export interface AnalyticsVisitDay { date: string; pv: number; uv: number; } export interface AnalyticsNamedCount { key: string; label: string; count: number; } export interface AnalyticsTopViewPost { id: number; title: string; view_count: number; board_id: number; } export interface AnalyticsOverview { range_days: number; today_pv: number; today_uv: number; visits: AnalyticsVisitDay[]; posts_series: AnalyticsDayCount[]; comments_series: AnalyticsDayCount[]; registers_series: AnalyticsDayCount[]; boards: AnalyticsNamedCount[]; post_types: AnalyticsNamedCount[]; top_viewed: AnalyticsTopViewPost[]; } export async function fetchAdminAnalytics( cookieHeader?: string, range: "7d" | "30d" = "7d" ): Promise { const res = await fetch( `${API_BASE}/api/admin/analytics/overview?range=${range}`, ssrInit(cookieHeaders(cookieHeader)) ); if (!res.ok) throw new Error("failed"); return res.json(); } export async function apiAdminAnalytics(range: "7d" | "30d" = "7d"): Promise { const res = await fetchWithRefresh(`/api/admin/analytics/overview?range=${range}`, { method: "GET", headers: clientHeaders(), }); if (!res.ok) throw new Error("failed"); return res.json(); } export async function apiTelemetryPageView(path: string, ref: string): Promise { // 游客尚无 CSRF cookie 时该 POST 必然 403(响应补发的 cookie 本请求用不上), // 直接跳过:后续 GET 补发 cookie 之后的页面浏览照常上报 if (!getCSRFToken()) return; try { await fetchWithRefresh("/api/telemetry/pageview", { method: "POST", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ path, ref }), }); } catch { /* 埋点失败不影响浏览 */ } } // ===== 访问统计(管理端,仅超管/站长) ===== export interface VisitDayPoint { date: string; pv: number; uv: number; } export interface VisitNamedCount { key: string; label: string; count: number; } export interface VisitPageRow { path: string; pv: number; uv: number; share: number; } export interface VisitOverview { range_days: number; trend: VisitDayPoint[]; today_pv: number; today_uv: number; yesterday_pv: number; yesterday_uv: number; logged_in_pv: number; devices: VisitNamedCount[]; sources: VisitNamedCount[]; top_pages: VisitPageRow[]; } export interface VisitMinutePoint { minute: string; pv: number; } export interface VisitEventItem { id: number; created_at: string; kind: string; path: string; ref_host: string; ref_kind: string; user_id: number; username: string; ip: string; device: string; os: string; browser: string; bot: string; status: number; } export interface VisitRealtime { minutes: VisitMinutePoint[]; pv_5min: number; pv_30min: number; online: number; top_pages: VisitNamedCount[]; recent: VisitEventItem[]; } export interface VisitSourcesKindDay { date: string; items: VisitNamedCount[]; } export interface VisitSources { range_days: number; kinds: VisitNamedCount[]; search_terms: VisitNamedCount[]; external: VisitNamedCount[]; trend: VisitSourcesKindDay[]; } export interface VisitPages { range_days: number; total: number; items: VisitPageRow[]; } export interface VisitVisitors { range_days: number; devices: VisitNamedCount[]; oses: VisitNamedCount[]; browsers: VisitNamedCount[]; logged_in_pv: number; guest_pv: number; new_visitors: number; returning: number; hourly: VisitNamedCount[]; } export interface VisitSecurity { range_days: number; bots: VisitNamedCount[]; bot_trend: VisitDayPoint[]; probe_status: VisitNamedCount[]; probe_ips: VisitNamedCount[]; login_failed: number; failed_users: number; } export interface VisitEventsQuery { kind?: string; bot?: string; path?: string; ip?: string; start?: string; end?: string; page?: number; size?: number; } export interface VisitEventsResult { total: number; items: VisitEventItem[]; } export interface VisitUsage { rows: number; bytes: number; } export interface VisitSettingsPayload { enabled: boolean; retention_days: number; usage: VisitUsage; dropped: number; } // SSR 首拉:透传 cookie async function fetchVisitStats(path: string, cookieHeader?: string): Promise { const res = await fetch(`${API_BASE}${path}`, ssrInit(cookieHeaders(cookieHeader))); if (!res.ok) throw new Error("failed"); return res.json(); } // 客户端拉取:带 CSRF + 自动续期 async function apiVisitStats(path: string, init?: RequestInit): Promise { const headers = init?.body ? clientHeaders({ "Content-Type": "application/json" }) : clientHeaders(); const res = await fetchWithRefresh(path, { method: init?.method ?? "GET", ...init, headers, }); if (!res.ok) throw new Error("failed"); return res.json(); } export function fetchStatsOverview(cookieHeader?: string, range = "7d") { return fetchVisitStats(`/api/admin/stats/overview?range=${range}`, cookieHeader); } export function apiStatsOverview(range = "7d") { return apiVisitStats(`/api/admin/stats/overview?range=${range}`); } export function fetchStatsRealtime(cookieHeader?: string) { return fetchVisitStats("/api/admin/stats/realtime", cookieHeader); } export function apiStatsRealtime() { return apiVisitStats("/api/admin/stats/realtime"); } export function fetchStatsSources(cookieHeader?: string, range = "7d") { return fetchVisitStats(`/api/admin/stats/sources?range=${range}`, cookieHeader); } export function apiStatsSources(range = "7d") { return apiVisitStats(`/api/admin/stats/sources?range=${range}`); } export function fetchStatsPages(cookieHeader?: string, range = "7d", q = "", page = 1, size = 20) { return fetchVisitStats( `/api/admin/stats/pages?range=${range}&q=${encodeURIComponent(q)}&page=${page}&size=${size}`, cookieHeader ); } export function apiStatsPages(range = "7d", q = "", page = 1, size = 20) { return apiVisitStats( `/api/admin/stats/pages?range=${range}&q=${encodeURIComponent(q)}&page=${page}&size=${size}` ); } export function fetchStatsVisitors(cookieHeader?: string, range = "7d") { return fetchVisitStats(`/api/admin/stats/visitors?range=${range}`, cookieHeader); } export function apiStatsVisitors(range = "7d") { return apiVisitStats(`/api/admin/stats/visitors?range=${range}`); } export function fetchStatsSecurity(cookieHeader?: string, range = "7d") { return fetchVisitStats(`/api/admin/stats/security?range=${range}`, cookieHeader); } export function apiStatsSecurity(range = "7d") { return apiVisitStats(`/api/admin/stats/security?range=${range}`); } export function fetchStatsEvents(cookieHeader?: string, query: VisitEventsQuery = {}) { const p = new URLSearchParams(); if (query.kind) p.set("kind", query.kind); if (query.bot) p.set("bot", query.bot); if (query.path) p.set("path", query.path); if (query.ip) p.set("ip", query.ip); if (query.start) p.set("start", query.start); if (query.end) p.set("end", query.end); p.set("page", String(query.page ?? 1)); p.set("size", String(query.size ?? 50)); return fetchVisitStats(`/api/admin/stats/events?${p.toString()}`, cookieHeader); } export function apiStatsEvents(query: VisitEventsQuery = {}) { const p = new URLSearchParams(); if (query.kind) p.set("kind", query.kind); if (query.bot) p.set("bot", query.bot); if (query.path) p.set("path", query.path); if (query.ip) p.set("ip", query.ip); if (query.start) p.set("start", query.start); if (query.end) p.set("end", query.end); p.set("page", String(query.page ?? 1)); p.set("size", String(query.size ?? 50)); return apiVisitStats(`/api/admin/stats/events?${p.toString()}`); } export function fetchStatsSettings(cookieHeader?: string) { return fetchVisitStats("/api/admin/stats/settings", cookieHeader); } export function apiStatsSettings() { return apiVisitStats("/api/admin/stats/settings"); } export function apiStatsSaveSettings(enabled: boolean, retentionDays: number) { return apiVisitStats("/api/admin/stats/settings", { method: "PUT", body: JSON.stringify({ enabled, retention_days: retentionDays }), }); } export function apiStatsPurge(days: number) { return apiVisitStats<{ ok: boolean }>("/api/admin/stats/purge", { method: "POST", body: JSON.stringify({ days }), }); } export type AdminChatRoomType = "group" | "direct"; export interface AdminChatParticipant { id: number; name: string; avatar: string; } export interface AdminChatMessage { id: number; room_id: number; room_name: string; room_type?: AdminChatRoomType; is_default: boolean; sender_id: number; sender_name: string; sender_avatar: string; participants?: AdminChatParticipant[]; content: string; recalled_at?: string | null; created_at: string; } export async function apiAdminChatMessages( page = 1, q = "", roomType: AdminChatRoomType = "group" ): Promise<{ messages: AdminChatMessage[]; total: number; page: number; }> { const params = new URLSearchParams({ page: String(page), room_type: roomType, }); if (q.trim()) params.set("q", q.trim()); const res = await fetchWithRefresh(`/api/admin/chat/messages?${params}`, { method: "GET", headers: clientHeaders(), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "获取聊天消息失败"); return data; } export async function apiAdminRecallChatMessage(roomId: number, mid: number) { const res = await fetchWithRefresh(`/api/admin/chat/rooms/${roomId}/messages/${mid}`, { method: "DELETE", headers: clientHeaders(), }); return res.json(); } 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, }); const ct = res.headers.get("content-type") || ""; if (!ct.includes("application/json")) { if (res.status === 413) return { error: "附件过大" }; if (res.status >= 500) return { error: "上传失败,请稍后重试" }; return { error: "上传失败" }; } const data = (await res.json().catch(() => ({}))) as { attachment?: PostAttachment; error?: string; }; if (!res.ok) { return { error: data.error || (res.status === 413 ? "附件过大" : "上传失败") }; } return data; } 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`; } function filenameFromDisposition(header: string | null, fallback: string): string { if (!header) return fallback; const star = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(header); if (star?.[1]) { try { return decodeURIComponent(star[1]); } catch { return fallback; } } const quoted = /filename\s*=\s*"([^"]+)"/i.exec(header); if (quoted?.[1]) return quoted[1]; const plain = /filename\s*=\s*([^;]+)/i.exec(header); if (plain?.[1]) return plain[1].trim(); return fallback; } /** 下载帖子附件:走 fetch 以便处理 401/402 JSON,避免整页打开错误文档 */ export async function apiDownloadPostAttachment( postId: number | string, attId: number, fallbackName: string ): Promise<{ blob?: Blob; filename?: string; error?: string; status?: number }> { const res = await fetchWithRefresh(postAttachmentDownloadURL(postId, attId), { method: "GET", cache: "no-store", headers: clientHeaders(), }); if (!res.ok) { const data = (await res.json().catch(() => ({}))) as { error?: string }; return { error: data.error || "下载失败", status: res.status }; } const blob = await res.blob(); return { blob, filename: filenameFromDisposition(res.headers.get("Content-Disposition"), fallbackName), }; } 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 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 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 apiTogglePostLock(postId: string): Promise<{ locked: boolean }> { const res = await fetchWithRefresh(`/api/posts/${postId}/lock`, { 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(); } /** 当前用户仍有效的登录会话(安全设置 SSR / 客户端) */ export interface LoginDevice { id: number; ip: string; device_kind: "computer" | "mobile" | "tablet"; device_label: string; browser: string; current: boolean; created_at: string; } export interface LoginDevicesResponse { devices: LoginDevice[]; } export async function fetchLoginDevices( cookieHeader?: string, viewer?: { userAgent?: string; clientIP?: string } ): Promise { const headers: Record = {}; if (cookieHeader) headers.Cookie = cookieHeader; if (viewer?.userAgent) headers["X-J13-Client-UA"] = viewer.userAgent; if (viewer?.clientIP) headers["X-J13-Client-IP"] = viewer.clientIP; const res = await fetch(`${API_BASE}/api/me/login-devices`, ssrInit(headers)); if (res.status === 401) return []; if (!res.ok) throw new Error("获取登录设备失败"); const data = (await res.json()) as LoginDevicesResponse; return data.devices ?? []; } /** 剔除其它登录设备,该设备需重新输入密码登录 */ export async function apiRevokeLoginDevice( id: number ): Promise<{ ok?: boolean; error?: string }> { const res = await fetchWithRefresh(`/api/me/login-devices/${id}`, { method: "DELETE", headers: clientHeaders(), }); 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"; /** 图片来源:post=帖子插图,library_cover=书籍封面,library_content=书籍正文 */ source?: string; 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 / GIF,≤5MB;服务端自动转存 WebP,GIF 除外) export async function apiUploadPostImage( file: File | Blob, filename = "image.jpg", source?: string ): 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); if (source) form.append("source", source); const res = await fetchWithRefresh("/api/upload/image", { method: "POST", headers: clientHeaders(), body: form, }); const ct = res.headers.get("content-type") || ""; if (!ct.includes("application/json")) { if (res.status === 413) return { error: "图片过大" }; if (res.status >= 500) return { error: "上传失败,请稍后重试" }; return { error: "上传失败" }; } const data = (await res.json().catch(() => ({}))) as { url?: string; attachment?: MediaAttachment; error?: string; }; if (!res.ok) { return { error: data.error || (res.status === 413 ? "图片过大" : "上传失败") }; } return data; } // 选用一张本人历史上传的头像 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 apiToggleFavorite(postId: string) { const res = await fetchWithRefresh(`/api/posts/${postId}/favorite`, { method: "POST", headers: clientHeaders(), }); return res.json(); } export async function apiToggleFollow(userId: string | number) { const res = await fetchWithRefresh(`/api/users/${userId}/follow`, { 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 公告详情(仅已发布;转发 cookie 以便按登录态脱敏 hide) export async function fetchAnnouncementDetail( id: string, cookieHeader?: string ): Promise<{ announcement: Announcement } | null> { const res = await fetch( `${API_BASE}/api/announcements/${id}`, ssrInit(cookieHeaders(cookieHeader)) ); if (res.status === 404) return null; if (!res.ok) throw new Error("获取公告失败"); return res.json(); } // SSR 后台公告列表(含草稿) export async function fetchAdminAnnouncements( cookieHeader?: string ): Promise<{ announcements: Announcement[] }> { const res = await fetch( `${API_BASE}/api/admin/announcements`, ssrInit(cookieHeaders(cookieHeader)) ); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "获取公告列表失败"); return data as { announcements: Announcement[] }; } // 后台:公告列表(含草稿) 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; pinned?: 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 || "删除公告失败"); } } export async function apiAdminToggleAnnouncementPin( id: number ): Promise<{ announcement: Announcement }> { const res = await fetchWithRefresh(`/api/admin/announcements/${id}/pin`, { method: "POST", headers: clientHeaders(), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "置顶失败"); return data as { announcement: Announcement }; } // ---------- 后台徽章管理 ---------- export interface BadgeWithHolders extends Badge { holders: number; } export interface BadgeHolder { user_id: number; username: string; nickname: string; awarded_at: string; } export async function apiAdminListBadges(): Promise<{ badges: BadgeWithHolders[] }> { const res = await fetchWithRefresh("/api/admin/badges", { headers: clientHeaders() }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "获取徽章列表失败"); } return res.json(); } export interface BadgeInput { name: string; icon: string; color: string; } async function saveBadge(method: "POST" | "PUT", url: string, input: BadgeInput): Promise { 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.badge ?? data) as Badge; } export function apiAdminCreateBadge(input: BadgeInput) { return saveBadge("POST", "/api/admin/badges", input); } export function apiAdminUpdateBadge(id: number, input: BadgeInput) { return saveBadge("PUT", `/api/admin/badges/${id}`, input); } export async function apiAdminDeleteBadge(id: number): Promise { const res = await fetchWithRefresh(`/api/admin/badges/${id}`, { method: "DELETE", headers: clientHeaders(), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "删除徽章失败"); } } export async function apiAdminGrantBadge(id: number, userId: number): Promise { const res = await fetchWithRefresh(`/api/admin/badges/${id}/grant`, { method: "POST", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ user_id: userId }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "授予失败"); } } export async function apiAdminRevokeBadge(id: number, userId: number): Promise { const res = await fetchWithRefresh(`/api/admin/badges/${id}/grant/${userId}`, { method: "DELETE", headers: clientHeaders(), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "撤销失败"); } } export async function apiAdminBadgeHolders(id: number): Promise<{ holders: BadgeHolder[] }> { const res = await fetchWithRefresh(`/api/admin/badges/${id}/holders`, { headers: clientHeaders(), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "获取持有者失败"); } return res.json(); } /** SSR / 公开:分页公告列表 */ export async function fetchAnnouncementsPage( page = 1, size = 20 ): Promise<{ announcements: Announcement[]; total: number; page: number }> { const res = await fetch( `${API_BASE}/api/announcements?page=${page}&size=${size}`, ssrInit() ); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "获取公告失败"); return data as { announcements: Announcement[]; total: number; page: number }; } export type TimelineGitItem = { date: string; title: string; body: string; source_url?: string; sha?: string; }; export async function apiTimelineFromGit(input: { urls: string[]; follow_pages?: boolean; }): Promise<{ items: TimelineGitItem[]; warning?: string; error?: string }> { const res = await fetchWithRefresh("/api/timeline/from-git", { method: "POST", 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 { items: TimelineGitItem[]; warning?: string; error?: string }; } export async function apiTimelineFromReleases(input: { urls: string[]; }): Promise<{ items: TimelineGitItem[]; warning?: string; error?: string }> { const res = await fetchWithRefresh("/api/timeline/from-releases", { method: "POST", 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 { items: TimelineGitItem[]; warning?: string; error?: string }; } /** SSR / 公开:已发布单页目录 */ export async function fetchPublishedSitePages(): Promise<{ pages: SitePageSummary[] }> { const res = await fetch(`${API_BASE}/api/pages`, ssrInit()); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "获取页面失败"); return { pages: data.pages ?? [] }; } export async function fetchSitePageBySlug( slug: string ): Promise<{ page: SitePage } | null> { const res = await fetch(`${API_BASE}/api/pages/${encodeURIComponent(slug)}`, ssrInit()); if (res.status === 404) return null; if (!res.ok) throw new Error("获取页面失败"); return res.json(); } export async function apiAdminListSitePages(): Promise<{ pages: SitePage[] }> { const res = await fetchWithRefresh("/api/admin/pages", { headers: clientHeaders() }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "获取单页失败"); return data as { pages: SitePage[] }; } export async function fetchAdminSitePages( cookieHeader?: string ): Promise<{ pages: SitePage[] }> { const res = await fetch(`${API_BASE}/api/admin/pages`, ssrInit(cookieHeaders(cookieHeader))); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "获取单页失败"); return data as { pages: SitePage[] }; } async function saveSitePage( method: "POST" | "PUT", url: string, input: SitePageInput ): Promise<{ page: SitePage }> { 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 { page: SitePage }; } export function apiAdminCreateSitePage(input: SitePageInput) { return saveSitePage("POST", "/api/admin/pages", input); } export function apiAdminUpdateSitePage(id: number, input: SitePageInput) { return saveSitePage("PUT", `/api/admin/pages/${id}`, input); } export async function apiAdminDeleteSitePage(id: number): Promise { const res = await fetchWithRefresh(`/api/admin/pages/${id}`, { method: "DELETE", headers: clientHeaders(), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "删除页面失败"); } } /** 彻底删除单页(仅限已软删;删除后 slug 释放可复用) */ export async function apiAdminPurgeSitePage(id: number): Promise { const res = await fetchWithRefresh(`/api/admin/pages/${id}/purge`, { method: "DELETE", headers: clientHeaders(), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "彻底删除失败"); } } // ===== 书库(公开 + 管理) ===== /** SSR / 公开:已发布书库目录 */ export async function fetchLibraryList(): Promise<{ docs: LibraryDocListItem[] }> { const res = await fetch(`${API_BASE}/api/library`, ssrInit()); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "获取书库失败"); return { docs: data.docs ?? [] }; } /** SSR / 公开:书库条目详情(404 返 null) */ export async function fetchLibraryDocBySlug(slug: string): Promise<{ doc: LibraryDoc } | null> { const res = await fetch(`${API_BASE}/api/library/${encodeURIComponent(slug)}`, ssrInit()); if (res.status === 404) return null; if (!res.ok) throw new Error("获取条目失败"); return res.json(); } /** SSR:管理端书库目录(含未发布) */ export async function fetchAdminLibraryDocs( cookieHeader?: string ): Promise<{ docs: LibraryDoc[] }> { const res = await fetch(`${API_BASE}/api/admin/library/docs`, ssrInit(cookieHeaders(cookieHeader))); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "获取书库失败"); return data as { docs: LibraryDoc[] }; } export async function apiAdminListLibraryDocs(): Promise<{ docs: LibraryDoc[] }> { const res = await fetchWithRefresh("/api/admin/library/docs", { headers: clientHeaders() }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "获取书库失败"); return data as { docs: LibraryDoc[] }; } async function saveLibraryDoc( method: "POST" | "PUT", url: string, input: LibraryInput ): Promise<{ doc: LibraryDoc }> { 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 { doc: LibraryDoc }; } export function apiAdminCreateLibraryDoc(input: LibraryInput) { return saveLibraryDoc("POST", "/api/admin/library/docs", input); } export function apiAdminUpdateLibraryDoc(id: number, input: LibraryInput) { return saveLibraryDoc("PUT", `/api/admin/library/docs/${id}`, input); } export async function apiAdminDeleteLibraryDoc(id: number): Promise { const res = await fetchWithRefresh(`/api/admin/library/docs/${id}`, { method: "DELETE", headers: clientHeaders(), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "删除条目失败"); } } /** 彻底删除条目(仅限已软删;清除章节/文件行与磁盘文件,slug 释放可复用) */ export async function apiAdminPurgeLibraryDoc(id: number): Promise { const res = await fetchWithRefresh(`/api/admin/library/docs/${id}/purge`, { method: "DELETE", headers: clientHeaders(), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "彻底删除失败"); } } /** 上传文件到书库条目(multipart;扩展名/大小由后端白名单校验) */ export async function apiAdminUploadLibraryFile( docId: number, file: File ): Promise<{ file: LibraryFileItem; error?: string }> { const form = new FormData(); form.append("file", file, file.name); const res = await fetchWithRefresh(`/api/admin/library/docs/${docId}/files`, { method: "POST", headers: clientHeaders(), body: form, }); const data = await res.json().catch(() => ({})); if (!res.ok) return { file: null as unknown as LibraryFileItem, error: data.error || "上传失败" }; return data as { file: LibraryFileItem }; } export async function apiAdminDeleteLibraryFile(fid: number): Promise { const res = await fetchWithRefresh(`/api/admin/library/files/${fid}`, { method: "DELETE", headers: clientHeaders(), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "删除文件失败"); } } /** 书库导入报告(POST /api/admin/library/import) */ export interface LibraryImportItem { slug: string; title: string; action: "created" | "overwritten" | string; sections: number; files: number; } export interface LibraryImportFailure { slug: string; title: string; /** conflict=slug 被在用条目占用(可改 overwrite 重导);deleted=被软删条目占用;invalid/failed */ reason: "conflict" | "deleted" | "invalid" | "failed" | string; error: string; } export interface LibraryImportReport { mode: string; total: number; imported: LibraryImportItem[]; failed: LibraryImportFailure[]; } /** 上传书库导出 ZIP 恢复书籍;mode=create(冲突跳过)/ overwrite(覆盖同名) */ export async function apiAdminImportLibraryZip( file: File, mode: "create" | "overwrite" ): Promise { const form = new FormData(); form.append("file", file, file.name); form.append("mode", mode); const res = await fetchWithRefresh(`/api/admin/library/import`, { method: "POST", headers: clientHeaders(), body: form, }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "导入失败"); return (data as { report: LibraryImportReport }).report; } // ===== 书库章节(两级树)===== export interface LibrarySectionInput { parent_id?: number | null; title: string; content?: string; } /** 批量导入的单个 md 文件 */ export interface LibraryImportFileInput { filename: string; content: string; } /** 导入 md 拆章入参:多文件按传入顺序依次拆章;split_level h1/h2/none 对每个文件生效 */ export interface LibraryImportMdInput { files: LibraryImportFileInput[]; split_level: "h1" | "h2" | "none"; } export async function apiAdminCreateLibrarySection( docId: number, input: LibrarySectionInput ): Promise<{ section: LibrarySectionItem }> { const res = await fetchWithRefresh(`/api/admin/library/docs/${docId}/sections`, { method: "POST", headers: clientHeaders(), body: JSON.stringify(input), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "创建章节失败"); return data as { section: LibrarySectionItem }; } export async function apiAdminUpdateLibrarySection( sid: number, input: Pick ): Promise<{ section: LibrarySectionItem }> { const res = await fetchWithRefresh(`/api/admin/library/sections/${sid}`, { method: "PUT", headers: clientHeaders(), body: JSON.stringify(input), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "保存章节失败"); return data as { section: LibrarySectionItem }; } export async function apiAdminDeleteLibrarySection(sid: number): Promise { const res = await fetchWithRefresh(`/api/admin/library/sections/${sid}`, { method: "DELETE", headers: clientHeaders(), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "删除章节失败"); } } export async function apiAdminMoveLibrarySection( sid: number, direction: "up" | "down" ): Promise { const res = await fetchWithRefresh(`/api/admin/library/sections/${sid}/move`, { method: "POST", headers: clientHeaders(), body: JSON.stringify({ direction }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "移动章节失败"); } } export async function apiAdminImportLibraryMarkdownBatch( docId: number, input: LibraryImportMdInput ): Promise<{ created: number; sections: LibrarySectionItem[] }> { const res = await fetchWithRefresh(`/api/admin/library/docs/${docId}/import-md-batch`, { method: "POST", headers: clientHeaders(), body: JSON.stringify(input), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "导入失败"); return data as { created: number; sections: LibrarySectionItem[] }; } // ===== 用户管理(管理员) ===== // 后台用户列表项: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; can_manage_messages?: boolean; // 账号级渠道权限(公告/单页/书库/广告;角色固有之外的增量授予) perm_overrides?: string[]; points?: number; total_points?: number; level?: number; 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; sort?: string; order?: "asc" | "desc"; } 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); if (query.sort && query.sort !== "id") params.set("sort", query.sort); if (query.order === "asc") params.set("order", "asc"); else if (query.sort && query.sort !== "id") params.set("order", query.order || "desc"); 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 待审数量(帖子 + 评论);失败时返回零,不阻塞仪表盘 */ export interface PendingCounts { posts: number; comments: number; } 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 }; } // 站长授予/撤销站点消息管理权限 export async function apiAdminSetUserMessages( id: number, canManageMessages: boolean ): Promise<{ user: AdminUser }> { const res = await fetchWithRefresh(`/api/admin/users/${id}/messages`, { method: "PUT", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ can_manage_messages: canManageMessages }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "消息权限更新失败"); return data as { user: AdminUser }; } // 读取账号级渠道权限(超管/站长;前台快捷菜单的授权弹窗回显用) export async function apiAdminGetUserPerms( id: number ): Promise<{ perm_overrides: string[] }> { const res = await fetchWithRefresh(`/api/admin/users/${id}/perms`, { headers: clientHeaders(), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "渠道权限加载失败"); return data as { perm_overrides: string[] }; } // 超管/站长授予/撤销账号级渠道权限(公告/单页/书库/广告) export async function apiAdminSetUserPerms( id: number, perms: string[] ): Promise<{ user: AdminUser }> { const res = await fetchWithRefresh(`/api/admin/users/${id}/perms`, { method: "PUT", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ perms }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "渠道权限更新失败"); return data as { user: AdminUser }; } // 管理员手动调整用户积分(加分计累计可升级;扣分仅扣余额不降级),理由必填 export interface AdminAdjustPointsResult { balance: number; total_points: number; level: number; } export async function apiAdminAdjustUserPoints( id: number, delta: number, note: string ): Promise { const res = await fetchWithRefresh(`/api/admin/users/${id}/points`, { method: "PUT", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ delta, note }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "积分调整失败"); return data as AdminAdjustPointsResult; } // 管理端查询某用户积分流水(分页,id DESC) export async function apiAdminUserPointLedger( id: number, page = 1 ): Promise<{ items: PointLedgerItem[]; total: number; page: number; size: number }> { const res = await fetchWithRefresh(`/api/admin/users/${id}/points/ledger?page=${page}`, { headers: clientHeaders(), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "获取积分流水失败"); return data as { items: PointLedgerItem[]; total: number; page: number; size: number }; } // 某用户的登录历史(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 type LegacyUserReport = { total: number; imported: number; skipped: number; excluded: number; avatar_written: number; conflicts?: string[]; avatar_missing?: string[]; failed?: string[]; }; export type LegacyBoardReport = { created: number; reused: number; names?: string[]; }; export type LegacyPostReport = { total: number; imported: number; skipped: number; excluded: number; excluded_detail?: string[]; polls_skipped: number; poll_titles?: string[]; owner_fallback?: string[]; images_written: number; images_missing?: string[]; failed?: string[]; }; export type LegacyCommentReport = { total: number; imported: number; skipped: number; excluded: number; excluded_detail?: string[]; skipped_detail?: string[]; owner_fallback?: string[]; images_written: number; images_missing?: string[]; failed?: string[]; }; // ===== 预检清单(dry-run 返回,供勾选导入范围) ===== export type LegacyUserPreview = { id: number; username: string; nickname: string; posts: number; comments: number; exists: boolean; has_avatar: boolean; }; export type LegacyPostPreview = { id: number; title: string; author: string; board: string; created_at: string; poll: boolean; published: boolean; }; export type LegacyCommentPreview = { id: number; post_id: number; post_title: string; author: string; excerpt: string; created_at: string; published: boolean; }; export type LegacyImportReport = { dry_run: boolean; users: LegacyUserReport; boards?: LegacyBoardReport; posts?: LegacyPostReport; comments?: LegacyCommentReport; user_list?: LegacyUserPreview[]; post_list?: LegacyPostPreview[]; comment_list?: LegacyCommentPreview[]; notes?: string[]; }; export type LegacyImportOptions = { withContent: boolean; dryRun: boolean; /** 不创建账号的旧用户 ID(内容仍按归属规则落位) */ skipUsers?: number[]; /** 内容归到站长的旧用户 ID */ operatorUsers?: number[]; /** 旧用户 ID → 指定已有账号用户名(内容归属) */ userMap?: { oldId: number; username: string }[]; /** 排除的旧帖 ID(其评论自动跳过) */ skipPosts?: number[]; /** 排除的旧评论 ID */ skipComments?: number[]; }; /** 上传旧站 SQLite 库(必填)、可选头像 / 帖子图片 zip 与勾选参数,导入用户与内容。幂等可重复执行 */ export async function apiAdminImportLegacy( source: string, dbFile: File, zipFile: File | null, imagesZipFile: File | null, opts: LegacyImportOptions ): Promise { const form = new FormData(); form.append("db_file", dbFile, dbFile.name || "jiang13.db"); if (zipFile) form.append("avatars_zip", zipFile, zipFile.name || "avatars.zip"); if (imagesZipFile) form.append("images_zip", imagesZipFile, imagesZipFile.name || "images.zip"); form.append("with_content", String(opts.withContent)); form.append("dry_run", String(opts.dryRun)); if (opts.skipUsers?.length) form.append("skip_users", opts.skipUsers.join(",")); if (opts.operatorUsers?.length) form.append("operator_users", opts.operatorUsers.join(",")); if (opts.userMap?.length) form.append("user_map", opts.userMap.map((m) => `${m.oldId}:${m.username}`).join(",")); if (opts.skipPosts?.length) form.append("skip_posts", opts.skipPosts.join(",")); if (opts.skipComments?.length) form.append("skip_comments", opts.skipComments.join(",")); const res = await fetchWithRefresh(`/api/admin/import/${source}`, { method: "POST", headers: clientHeaders(), body: form, }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "导入失败"); return (data.report || {}) as LegacyImportReport; } // ===== 板块管理(仅站长) ===== export async function fetchAdminBoards( cookieHeader?: string ): Promise<{ boards: AdminBoard[] }> { const res = await fetch( `${API_BASE}/api/admin/boards`, ssrInit(cookieHeaders(cookieHeader)) ); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "获取板块列表失败"); return data as { boards: AdminBoard[] }; } export async function apiAdminListBoards(): Promise<{ boards: AdminBoard[] }> { const res = await fetchWithRefresh("/api/admin/boards", { headers: clientHeaders(), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "获取板块列表失败"); return data as { boards: AdminBoard[] }; } export async function apiAdminCreateBoard( input: AdminBoardInput ): Promise<{ board: AdminBoard }> { const res = await fetchWithRefresh("/api/admin/boards", { method: "POST", 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 { board: AdminBoard }; } export async function apiAdminUpdateBoard( id: number, input: AdminBoardInput ): Promise<{ board: AdminBoard }> { const res = await fetchWithRefresh(`/api/admin/boards/${id}`, { method: "PUT", 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 { board: AdminBoard }; } export async function apiAdminDeleteBoard(id: number): Promise { const res = await fetchWithRefresh(`/api/admin/boards/${id}`, { method: "DELETE", headers: clientHeaders(), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "删除板块失败"); } export async function apiAdminReorderBoards(ids: number[]): Promise { const res = await fetchWithRefresh("/api/admin/boards/reorder", { method: "PUT", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ ids }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "排序失败"); } export async function apiAdminListBoardModerators( id: number ): Promise<{ moderators: AdminBoardModerator[] }> { const res = await fetchWithRefresh(`/api/admin/boards/${id}/moderators`, { headers: clientHeaders(), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "获取版主失败"); return data as { moderators: AdminBoardModerator[] }; } export async function apiAdminAddBoardModerator( boardId: number, username: string ): Promise { const res = await fetchWithRefresh(`/api/admin/boards/${boardId}/moderators`, { method: "POST", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ username }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "添加版主失败"); } export async function apiAdminRemoveBoardModerator( boardId: number, uid: number ): Promise { const res = await fetchWithRefresh( `/api/admin/boards/${boardId}/moderators/${uid}`, { method: "DELETE", headers: clientHeaders(), } ); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "移除版主失败"); } // ===== 站长用户内容档案 ===== 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; /** 所属帖内相对楼层号(0 = 评论已不可见) */ floor: number; is_root: boolean; } 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 fetchAdminUserAuditPosts( id: number, page = 1, cookieHeader?: string ): Promise> { const res = await fetch( `${API_BASE}/api/admin/users/${id}/posts?page=${page}`, ssrInit(cookieHeaders(cookieHeader)) ); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "获取帖子失败"); return data as AdminAuditListResponse; } 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; publish_without_review?: boolean; site_name?: string; site_description?: string; site_wordmark?: string; site_slogan?: string; site_keywords?: string[]; logo_light_url?: string; logo_dark_url?: string; favicon_url?: string; brand_mark?: BrandMark; brand_logo_size?: BrandLogoSize; brand_logo_fit?: BrandLogoFit; footer_links?: FooterLink[]; allow_register?: boolean; allow_comments?: boolean; url_style?: UrlStyle; /** 移动端底栏显示的 tab(固定顺序子集,整体替换保存;空数组会被后端拒绝) */ mobile_tabs?: MobileTabKey[]; /** 桌面端 Header 主导航(整体替换保存;空数组表示全部隐藏) */ header_nav?: HeaderNavItem[]; comments_require_login?: boolean; allow_messages?: boolean; post_cooldown_hours?: number; points_checkin_base?: number; points_streak_every_days?: number; points_streak_bonus?: number; points_post_reward?: number; points_post_daily_cap?: number; points_reply_reward?: number; points_reply_daily_cap?: number; points_recommend_reward?: number; post_edit_lock_hours?: number; comment_edit_lock_hours?: number; necro_reply_after_hours?: number; necro_reply_penalty?: number; /** 用户等级体系(数量/每级阈值/每级色板 key);整体替换保存 */ levels?: LevelDef[]; levels_fx?: boolean; code_block_auto_fold?: boolean; code_block_fold_lines?: number; ui_animations?: boolean; anim_code_fold?: boolean; anim_smooth_scroll?: boolean; anim_chrome?: boolean; post_link_new_tab?: boolean; attachment_ext_limit?: boolean; attachment_exts?: string[]; attachment_max_mb?: number; attachment_max_count?: number; image_max_mb?: number; bg_site_url?: string; bg_site_mode?: string; bg_admin_url?: string; bg_admin_mode?: string; }; // 更新站点设置(字段可选);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); } /** 上传品牌图,只落盘返回 URL,不写入站点设置 */ export async function apiUploadBrand(slot: BrandSlot, file: File): Promise { const form = new FormData(); form.append("slot", slot); form.append("file", file, file.name || "brand.png"); const res = await fetchWithRefresh("/api/admin/upload/brand", { method: "POST", headers: clientHeaders(), body: form, }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "上传品牌图失败"); const url = String(data.url || ""); if (!url) throw new Error("上传品牌图失败"); return url; } /** 把本人媒体库图片复制进品牌目录,不写入站点设置 */ export async function apiBrandFromMedia(slot: BrandSlot, attachmentId: number): Promise { const res = await fetchWithRefresh("/api/admin/upload/brand/from-media", { method: "POST", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ attachment_id: attachmentId, slot }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "选用媒体库图片失败"); const url = String(data.url || ""); if (!url) throw new Error("选用媒体库图片失败"); return url; } /** 上传站点背景并立即绑定前台或后台 */ /** 超管上传站点背景图:只落盘返回 URL,需再 PUT settings 才绑定并广播 */ export async function apiUploadSiteBackground( surface: "site" | "admin", file: File ): Promise { const form = new FormData(); form.append("surface", surface); form.append("file", file, file.name || "background.jpg"); const res = await fetchWithRefresh("/api/admin/upload/background", { method: "POST", headers: clientHeaders(), body: form, }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "上传背景图失败"); const url = String(data.url || ""); if (!url) throw new Error("上传背景图失败"); return url; } /** 把本人媒体库图片复制进 backgrounds,不写入站点设置 */ export async function apiBackgroundFromMedia( surface: "site" | "admin", attachmentId: number ): Promise { const res = await fetchWithRefresh("/api/admin/upload/background/from-media", { method: "POST", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ attachment_id: attachmentId, surface }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "选用媒体库图片失败"); const url = String(data.url || ""); if (!url) throw new Error("选用媒体库图片失败"); return url; } /** 把本人媒体库图片复制一份作书籍封面素材,返回副本 URL 与尺寸(与原图解耦) */ export async function apiLibraryCoverFromMedia( attachmentId: number ): Promise<{ url: string; width: number; height: number }> { const res = await fetchWithRefresh("/api/admin/library/cover/from-media", { method: "POST", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ attachment_id: attachmentId }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "选用媒体库图片失败"); const url = String(data.url || ""); if (!url) throw new Error("选用媒体库图片失败"); const att = (data.attachment || {}) as { width?: number; height?: number }; return { url, width: Number(att.width) || 0, height: Number(att.height) || 0 }; } // ===== 板块(客户端,供角色授权弹窗勾选) ===== 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 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; /** 相对创建已编辑(服务端判定);无编辑时隐藏历史入口 */ edited: boolean; deleted: boolean; deleted_at?: string; created_at: string; /** 所属帖内相对楼层号(0 = 评论已不可见) */ floor: number; is_root: boolean; 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 apiCommentHistory( 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/comments/${commentId}/history?${params}`, { headers: clientHeaders() } ); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "获取编辑历史失败"); return data; } export interface PostEditHistoryItem { id: number; editor: { id: number; username: string; nickname: string; avatar: string; }; old_title: string; old_content: string; created_at: string; } /** 帖子编辑历史(登录可见;分页;默认每页 10 条) */ export async function apiPostHistory( postId: number, page = 1, size = 10 ): Promise<{ items: PostEditHistoryItem[]; total: number; page: number; size: number }> { const params = new URLSearchParams({ page: String(page), size: String(size) }); const res = await fetchWithRefresh( `/api/posts/${postId}/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(); } export async function fetchAdminContentComments( page = 1, status: AdminContentStatus = "live", cookieHeader?: string ): Promise<{ comments: AdminContentComment[]; total: number; page: number }> { const res = await fetch( `${API_BASE}/api/admin/content/comments?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" | "admin" | "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" | "admin" | "member"; last_read_message_id: number; muted: boolean; muted_until?: string | null; 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; /** 系统提示消息(如禁言通知):sender_id=0,居中灰字渲染 */ is_system?: boolean; /** 撤回操作者(已撤回时后端 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 apiGetUserProfile(id: number): Promise { const res = await fetchWithRefresh(`/api/users/${id}?page=1&size=1`, { headers: clientHeaders(), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "获取用户资料失败"); return data as UserProfile; } // 客户端:搜索用户以发起私聊 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 }; } // 客户端:禁言/解禁;durationMinutes 可选:0/缺省=永久,>0=指定分钟数 export async function apiMuteChatMember( roomId: number, userId: number, muted: boolean, durationMinutes?: number ): Promise<{ ok: boolean; muted: boolean; duration_minutes?: number }> { const res = await fetchWithRefresh( `/api/chat/rooms/${roomId}/members/${userId}/mute`, { method: "PUT", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ muted, duration_minutes: durationMinutes ?? 0 }), } ); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "禁言操作失败"); return data as { ok: boolean; muted: boolean; duration_minutes?: number }; } // 客户端:站长任命/撤销群管理员 export async function apiSetChatMemberRole( roomId: number, userId: number, role: "admin" | "member" ): Promise<{ ok: boolean; role: string }> { const res = await fetchWithRefresh( `/api/chat/rooms/${roomId}/members/${userId}/role`, { method: "PUT", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ role }), } ); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "设置群角色失败"); return data as { ok: boolean; role: string }; } // 客户端:邀请成员(用户名列表,群主/群管/监管) 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(); } export async function apiOperations>(path: string, method = "GET", body?: unknown): Promise { const res = await fetchWithRefresh(path, { method, cache: "no-store", headers: clientHeaders({ "Content-Type": "application/json" }), ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); const data = await res.json(); if (!res.ok) throw new Error(data.error || "操作失败,请重试"); return data as T; } /* ---------- 自助推广 / 赞助商 ---------- */ export interface AdDurationOption { days: number; label: string; price_hint: string; price_hint_text: string; } export interface AdPaymentOption { id: string; name: string; kind: "alipay" | "wechat" | "other" | string; qr_url: string; hint: string; enabled: boolean; } export interface AdConfig { enabled: boolean; panel_title: string; durations: AdDurationOption[]; payments: AdPaymentOption[]; } export interface SponsorItem { id: string; name: string; logo_url: string; link_url: string; hover_text: string; enabled: boolean; sort_order: number; } export interface SponsorsConfig { enabled: boolean; panel_title: string; items: SponsorItem[]; } export interface AdRecord { id: number; kind: string; status: string; contact_email: string; link_url: string; image_url?: string; title?: string; text_color?: string; bg_color?: string; duration_days: number; payment_id: string; buyer_note?: string; starts_at?: string | null; ends_at?: string | null; reject_reason?: string; sort_order: number; created_at: string; reviewed_at?: string | null; } export async function apiFetchCaptcha(): Promise<{ id: string; image: string }> { const res = await fetch("/api/captcha", { credentials: "include", cache: "no-store" }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error((data as { error?: string }).error || "获取验证码失败"); return data as { id: string; image: string }; } export async function apiAdsPublicConfig(): Promise { const res = await fetch("/api/ads/config", { credentials: "include", cache: "no-store" }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error((data as { error?: string }).error || "加载广告配置失败"); return data as AdConfig; } export async function apiSubmitAd(body: Record): Promise<{ ad: AdRecord; message: string }> { const res = await fetch("/api/ads", { method: "POST", credentials: "include", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify(body), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error((data as { error?: string }).error || "提交失败"); return data as { ad: AdRecord; message: string }; } export async function apiAdminSaveAdsConfig(cfg: AdConfig): Promise { return apiOperations("/api/admin/ads/config", "PUT", cfg); } export async function apiAdminListAds( status = "", page = 1 ): Promise<{ ads: AdRecord[]; total: number }> { const q = new URLSearchParams({ page: String(page), size: "30" }); if (status) q.set("status", status); return apiOperations(`/api/admin/ads?${q}`); } export async function fetchAdminAds( cookie?: string, status = "" ): Promise<{ ads: AdRecord[]; total: number }> { const q = new URLSearchParams({ page: "1", size: "50" }); if (status) q.set("status", status); const res = await fetch(`${API_BASE}/api/admin/ads?${q}`, { ...ssrInit(cookie ? { Cookie: cookie } : undefined), credentials: "include", }); if (!res.ok) throw new Error("加载广告列表失败"); return res.json(); } export async function fetchAdminAdsConfig(cookie?: string): Promise { const res = await fetch(`${API_BASE}/api/admin/ads/config`, { ...ssrInit(cookie ? { Cookie: cookie } : undefined), credentials: "include", }); if (!res.ok) throw new Error("加载广告配置失败"); return res.json(); } export async function apiAdminApproveAd(id: number) { return apiOperations(`/api/admin/ads/${id}/approve`, "PUT"); } export async function apiAdminRejectAd(id: number, reason = "") { return apiOperations(`/api/admin/ads/${id}/reject`, "PUT", { reason }); } export async function apiAdminDeleteAd(id: number) { return apiOperations(`/api/admin/ads/${id}`, "DELETE"); } export async function apiAdminUploadAdQR(file: File): Promise<{ url: string }> { const fd = new FormData(); fd.append("file", file); const res = await fetchWithRefresh("/api/admin/upload/ad-qr", { method: "POST", headers: clientHeaders(), body: fd, }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error((data as { error?: string }).error || "上传失败"); return data as { url: string }; } export async function apiAdminSaveSponsorsConfig(cfg: SponsorsConfig): Promise { return apiOperations("/api/admin/sponsors/config", "PUT", cfg); } export async function fetchAdminSponsorsConfig(cookie?: string): Promise { const res = await fetch(`${API_BASE}/api/admin/sponsors/config`, { ...ssrInit(cookie ? { Cookie: cookie } : undefined), credentials: "include", }); if (!res.ok) throw new Error("加载赞助商配置失败"); return res.json(); } // ===== 侧栏管理(布局配置 + 自定义 HTML 工具) ===== import type { SidebarConfig, SidebarPublicPayload, SidebarWidgetRecord, } from "@/lib/sidebarMeta"; export type { SidebarConfig, SidebarScope, SidebarScopeConfig, SidebarBlockRef, SidebarPublicPayload, SidebarWidgetPublic, SidebarWidgetRecord, } from "@/lib/sidebarMeta"; /** SSR 公开侧栏载荷;失败由调用方回落默认布局 */ export async function fetchSidebar(cookieHeader?: string): Promise { const res = await fetch(`${API_BASE}/api/sidebar`, ssrInit(cookieHeaders(cookieHeader))); if (!res.ok) throw new Error("获取侧栏配置失败"); return res.json(); } /** SSR 管理端侧栏配置(config + 全量工具含停用);失败由调用方提示 */ export async function fetchAdminSidebar( cookie?: string ): Promise<{ config: SidebarConfig; widgets: SidebarWidgetRecord[] }> { const res = await fetch(`${API_BASE}/api/admin/sidebar`, { ...ssrInit(cookie ? { Cookie: cookie } : undefined), credentials: "include", }); if (!res.ok) throw new Error("加载侧栏配置失败"); return res.json(); } /** 媒体库条目(管理后台全站图片盘点) */ export interface MediaLibraryItem { url: string; original_url?: string; // 同名原图(仅当原图与 WebP 并存) category: string; // image/avatar/background/ads/brand category_name: string; name: string; mime: string; size: number; width: number; height: number; uploader?: string; uploaded_at?: string; source_url?: string; // 来源回链(帖子/评论锚点/用户主页) source_label?: string; // 来源描述(如:帖子《…》下的评论) } /** SSR 管理端媒体库:全站图片盘点 */ export async function fetchAdminMediaLibrary( cookie?: string ): Promise<{ items: MediaLibraryItem[]; counts: Record; total: number }> { const res = await fetch(`${API_BASE}/api/admin/media-library`, { ...ssrInit(cookie ? { Cookie: cookie } : undefined), credentials: "include", }); if (!res.ok) throw new Error("加载媒体库失败"); return res.json(); } /** 客户端管理端媒体库(带令牌刷新),品牌选图等后台场景用 */ export async function apiAdminMediaLibrary(): Promise<{ items: MediaLibraryItem[]; counts: Record; total: number; }> { const res = await fetchWithRefresh("/api/admin/media-library", { headers: clientHeaders(), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "加载媒体库失败"); return data; } export async function apiAdminGetSidebar(): Promise<{ config: SidebarConfig; widgets: SidebarWidgetRecord[]; }> { return apiOperations("/api/admin/sidebar"); } export async function apiAdminSaveSidebarConfig(config: SidebarConfig): Promise<{ config: SidebarConfig }> { return apiOperations("/api/admin/sidebar/config", "PUT", { config }); } export async function apiAdminCreateSidebarWidget( body: Pick ): Promise<{ widget: SidebarWidgetRecord }> { return apiOperations("/api/admin/sidebar/widgets", "POST", body); } export async function apiAdminUpdateSidebarWidget( id: number, body: Pick ): Promise<{ ok: boolean }> { return apiOperations(`/api/admin/sidebar/widgets/${id}`, "PUT", body); } export async function apiAdminDeleteSidebarWidget(id: number): Promise<{ ok: boolean }> { return apiOperations(`/api/admin/sidebar/widgets/${id}`, "DELETE"); } /* ---------- 友情链接(申请审批制) ---------- */ export type FriendLinkStatus = "pending" | "approved" | "rejected"; export interface FriendLink { id: number; name: string; url: string; description?: string; logo_url?: string; category?: string; reciprocal_url?: string; sort_order: number; status: FriendLinkStatus; applicant_user_id?: number | null; reject_reason?: string; created_at: string; updated_at: string; } export interface FriendLinkApplyInput { name: string; url: string; description?: string; logo_url?: string; category?: string; reciprocal_url?: string; } export interface FriendLinkAdminInput { name: string; url: string; description?: string; logo_url?: string; category?: string; reciprocal_url?: string; sort_order?: number; } /** SSR 公开友情链接列表(仅 approved) */ export async function fetchFriendLinks(cookie?: string): Promise<{ links: FriendLink[] }> { const res = await fetch(`${API_BASE}/api/friend-links`, ssrInit(cookieHeaders(cookie))); if (!res.ok) throw new Error("获取友情链接失败"); return res.json(); } /** 用户提交友情链接申请(登录) */ export async function apiApplyFriendLink(body: FriendLinkApplyInput): Promise<{ link: FriendLink }> { return apiOperations("/api/friend-links/apply", "POST", body); } /** 我的申请记录(含待审/拒绝与拒绝理由) */ export async function apiMyFriendLinks(): Promise<{ links: FriendLink[] }> { return apiOperations("/api/friend-links/mine"); } /** 申请人修改自己的申请;已通过/已驳回的修改后回退待审 */ export async function apiUpdateMyFriendLink( id: number, body: FriendLinkApplyInput ): Promise<{ ok: boolean }> { return apiOperations(`/api/friend-links/${id}`, "PUT", body); } /** SSR 管理端全量列表(含 pending/rejected) */ export async function fetchAdminFriendLinks(cookie?: string): Promise<{ links: FriendLink[] }> { const res = await fetch(`${API_BASE}/api/admin/friend-links`, { ...ssrInit(cookie ? { Cookie: cookie } : undefined), credentials: "include", }); if (!res.ok) throw new Error("加载友情链接失败"); return res.json(); } export async function apiAdminListFriendLinks(): Promise<{ links: FriendLink[] }> { return apiOperations("/api/admin/friend-links"); } export async function apiAdminCreateFriendLink(body: FriendLinkAdminInput): Promise<{ link: FriendLink }> { return apiOperations("/api/admin/friend-links", "POST", body); } export async function apiAdminUpdateFriendLink(id: number, body: FriendLinkAdminInput): Promise<{ ok: boolean }> { return apiOperations(`/api/admin/friend-links/${id}`, "PUT", body); } export async function apiAdminSetFriendLinkStatus( id: number, status: FriendLinkStatus, reject_reason?: string ): Promise<{ ok: boolean }> { return apiOperations(`/api/admin/friend-links/${id}/status`, "POST", reject_reason ? { status, reject_reason } : { status }); } export async function apiAdminDeleteFriendLink(id: number): Promise<{ ok: boolean }> { return apiOperations(`/api/admin/friend-links/${id}`, "DELETE"); }