Files
jiang13-bbs/frontend/lib/api.ts
freefire 21174dc5cb fix: 主动登出后误弹「登录已失效」与游客埋点 403
- apiLogout 成功后复位 sessionSeen:主动登出不再被后续 401 误判为「被顶下线」,消除登出后登录页误弹 AccountGuard 弹窗,及其引发的二次 logout 清掉刚补发 CSRF cookie、首次登录报「CSRF token 缺失」的问题

- apiTelemetryPageView 无 CSRF cookie 时跳过上报:游客首屏(cookie 尚未由 GET 补发)不再产生必然失败的 403,cookie 补发后照常上报
2026-09-26 04:55:27 +08:00

4323 lines
132 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { CSRF_COOKIE } 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;
// 作为群管理员的群数量(/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;
}
export interface SitePageInput {
slug: string;
title: string;
content: string;
excerpt?: string;
published: boolean;
show_in_sidebar: boolean;
sort_order?: number;
}
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;
}
/** 板块右栏:板级统计 */
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<User, "id" | "username" | "nickname" | "avatar">;
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;
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;
/** 仅本人访问时下发的打码邮箱(j***@163.com) */
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;
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;
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<string, string> = {}): HeadersInit {
const headers: Record<string, string> = { ...extra };
const csrf = getCSRFToken();
if (csrf) headers["X-CSRF-Token"] = csrf;
return headers;
}
// ===== Refresh token 自动续期 =====
let refreshPromise: Promise<{ ok: boolean; banned: boolean }> | null = null;
// 被封禁通知去重:同一登录生命周期内只强制下线/弹一次,
// 重新登录成功(apiLogin)后复位,使"解封后再次被封"仍能再次提示
let bannedNotified = false;
// 登录态被顶/被剔除通知去重(其他浏览器登录、安全设置剔除设备等)。
// 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 由浏览器自动携带)
async function apiRefresh(): Promise<{ ok: boolean; banned: boolean }> {
try {
const res = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include",
headers: clientHeaders(),
});
if (res.ok) return { ok: true, banned: false };
if (res.status === 403) {
const data = await res.json().catch(() => ({} as { code?: string }));
if (data.code === "account_banned") return { ok: false, banned: true };
}
return { ok: false, banned: false };
} catch {
return { ok: false, banned: false };
}
}
// 带自动续期的 fetch:遇到 401 时尝试 refresh,成功后重试原请求;
// 任意环节识别到账号封禁,派发全局强制下线事件(只派一次)
async function fetchWithRefresh(url: string, init: RequestInit): Promise<Response> {
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) {
// 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<PostsResponse> {
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<PostsResponse>;
return { posts: data.posts || [] };
}
// SSR 首页聚合数据;失败时抛错,由首页调用方 try/catch 降级为无侧栏模块
export async function fetchOverview(cookieHeader?: string): Promise<OverviewResponse> {
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<BoardSidebarResponse> {
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<LeaderboardResponse> {
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<LeaderboardMe | null> {
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;
}
/** 与后端 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,
};
function normalizeAttachmentExts(raw: unknown): string[] {
if (!Array.isArray(raw)) return [...DEFAULT_ATTACHMENT_EXTS];
const seen = new Set<string>();
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<PublicSettings> | 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),
levels: normalizeLevels(data?.levels),
levels_fx: data?.levels_fx !== false,
};
}
/** 内容锁定 4 键:小时 0–8760 / 积分 0–100 有效整数,越界/异常回落默认值 */
function normalizeLockSettings(
data: Partial<PublicSettings> | 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<PublicSettings> | 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<PublicSettings> {
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<PublicSettings>);
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<PublicSiteState> {
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<PublicSiteState>;
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<CommentsResponse> {
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();
}
// 评论定位:返回楼层号(与楼层分页口径一致),0 = 不存在。
// 通知/主页评论深链 #comment-{id} 跨页时用它换到正确页码。
export async function fetchCommentLocation(
postId: string | number,
commentId: number
): Promise<number> {
const res = await fetch(`${API_BASE}/api/posts/${postId}/comments/${commentId}`);
if (!res.ok) return 0;
const data = (await res.json()) as { floor?: number };
return data.floor || 0;
}
export async function fetchUserProfile(
id: string,
page = 1,
cookieHeader?: string,
sort: string = "new"
): Promise<UserProfile> {
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<MeResponse> {
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<UserCommentsResponse> {
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<UserFavoritesResponse> {
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<UserFollowingResponse> {
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<NotificationsResponse> {
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<MeResponse> {
const res = await fetch("/api/me", {
credentials: "include",
cache: "no-store",
});
const 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) {
// 曾登录、现在明确是游客:登录态在别处被顶/被剔除(其他浏览器登录、
// 设备剔除等)。挂载校正/回前台对账/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<string, number>;
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<AnalyticsOverview> {
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<AnalyticsOverview> {
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<void> {
// 游客尚无 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_hosts: 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<T>(path: string, cookieHeader?: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, ssrInit(cookieHeaders(cookieHeader)));
if (!res.ok) throw new Error("failed");
return res.json();
}
// 客户端拉取:带 CSRF + 自动续期
async function apiVisitStats<T>(path: string, init?: RequestInit): Promise<T> {
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<VisitOverview>(`/api/admin/stats/overview?range=${range}`, cookieHeader);
}
export function apiStatsOverview(range = "7d") {
return apiVisitStats<VisitOverview>(`/api/admin/stats/overview?range=${range}`);
}
export function fetchStatsRealtime(cookieHeader?: string) {
return fetchVisitStats<VisitRealtime>("/api/admin/stats/realtime", cookieHeader);
}
export function apiStatsRealtime() {
return apiVisitStats<VisitRealtime>("/api/admin/stats/realtime");
}
export function fetchStatsSources(cookieHeader?: string, range = "7d") {
return fetchVisitStats<VisitSources>(`/api/admin/stats/sources?range=${range}`, cookieHeader);
}
export function apiStatsSources(range = "7d") {
return apiVisitStats<VisitSources>(`/api/admin/stats/sources?range=${range}`);
}
export function fetchStatsPages(cookieHeader?: string, range = "7d", q = "", page = 1, size = 20) {
return fetchVisitStats<VisitPages>(
`/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<VisitPages>(
`/api/admin/stats/pages?range=${range}&q=${encodeURIComponent(q)}&page=${page}&size=${size}`
);
}
export function fetchStatsVisitors(cookieHeader?: string, range = "7d") {
return fetchVisitStats<VisitVisitors>(`/api/admin/stats/visitors?range=${range}`, cookieHeader);
}
export function apiStatsVisitors(range = "7d") {
return apiVisitStats<VisitVisitors>(`/api/admin/stats/visitors?range=${range}`);
}
export function fetchStatsSecurity(cookieHeader?: string, range = "7d") {
return fetchVisitStats<VisitSecurity>(`/api/admin/stats/security?range=${range}`, cookieHeader);
}
export function apiStatsSecurity(range = "7d") {
return apiVisitStats<VisitSecurity>(`/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<VisitEventsResult>(`/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<VisitEventsResult>(`/api/admin/stats/events?${p.toString()}`);
}
export function fetchStatsSettings(cookieHeader?: string) {
return fetchVisitStats<VisitSettingsPayload>("/api/admin/stats/settings", cookieHeader);
}
export function apiStatsSettings() {
return apiVisitStats<VisitSettingsPayload>("/api/admin/stats/settings");
}
export function apiStatsSaveSettings(enabled: boolean, retentionDays: number) {
return apiVisitStats<VisitSettingsPayload>("/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<EconomyStats & { error?: string }> {
const res = await fetchWithRefresh("/api/admin/economy", {
method: "GET",
headers: clientHeaders(),
});
return res.json();
}
export async function fetchAdminEconomyStats(
cookieHeader?: string
): Promise<EconomyStats> {
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<LoginDevice[]> {
const headers: Record<string, string> = {};
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";
size: number;
width: number;
height: number;
created_at: string;
}
// 上传裁剪后的头像(Blob 必须是 WebP;不要手动设置 Content-Type,交给浏览器加 multipart 边界)
export async function apiUploadAvatar(
file: Blob
): Promise<{ url?: string; error?: string }> {
const form = new FormData();
form.append("file", file, "avatar.webp");
const res = await fetchWithRefresh("/api/upload/avatar", {
method: "POST",
headers: clientHeaders(),
body: form,
});
return res.json();
}
// 上传帖子插图(JPEG / PNG / WebP,≤5MB)
export async function apiUploadPostImage(
file: File | Blob,
filename = "image.jpg"
): Promise<{ url?: string; attachment?: MediaAttachment; error?: string }> {
const form = new FormData();
const name =
file instanceof File && file.name
? file.name
: filename;
form.append("file", file, name);
const res = await fetchWithRefresh("/api/upload/image", {
method: "POST",
headers: clientHeaders(),
body: form,
});
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<NotificationsResponse> {
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<CheckinStatus> {
const res = await fetchWithRefresh("/api/checkin", {
headers: clientHeaders(),
});
if (!res.ok) throw new Error("获取签到状态失败");
return res.json();
}
// 执行签到;失败(如今日已签,409)抛出后端消息
export async function apiDoCheckin(): Promise<CheckinStatus> {
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<void> {
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<Badge> {
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<void> {
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<void> {
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<void> {
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 };
}
/** 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<void> {
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 || "删除页面失败");
}
}
// ===== 用户管理(管理员) =====
// 后台用户列表项: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;
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<AdminUsersResponse> {
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<PendingCounts> {
const res = await fetch(
`${API_BASE}/api/admin/moderation/counts`,
ssrInit(cookieHeaders(cookieHeader))
);
if (!res.ok) return { posts: 0, comments: 0 };
return res.json();
}
// 客户端后台用户列表(走 rewrite,携带 cookie + CSRF)
export async function apiAdminListUsers(query: AdminUsersQuery = {}): Promise<AdminUsersResponse> {
const res = await fetchWithRefresh(`/api/admin/users?${adminUsersParams(query)}`, {
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 interface AdminAdjustPointsResult {
balance: number;
total_points: number;
level: number;
}
export async function apiAdminAdjustUserPoints(
id: number,
delta: number,
note: string
): Promise<AdminAdjustPointsResult> {
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<LoginLogsResponse> {
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;
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;
polls_skipped: number;
poll_titles?: string[];
owner_fallback?: string[];
failed?: string[];
};
export type LegacyCommentReport = {
total: number;
imported: number;
skipped: number;
skipped_detail?: string[];
owner_fallback?: string[];
failed?: string[];
};
export type LegacyImportReport = {
dry_run: boolean;
users: LegacyUserReport;
boards?: LegacyBoardReport;
posts?: LegacyPostReport;
comments?: LegacyCommentReport;
notes?: string[];
};
export type LegacyImportOptions = {
withContent: boolean;
dryRun: boolean;
};
/** 上传旧站 SQLite 库(必填)与可选头像 zip,导入用户与内容。幂等可重复执行 */
export async function apiAdminImportLegacy(
source: string,
dbFile: File,
zipFile: File | null,
opts: LegacyImportOptions
): Promise<LegacyImportReport> {
const form = new FormData();
form.append("db_file", dbFile, dbFile.name || "jiang13.db");
if (zipFile) form.append("avatars_zip", zipFile, zipFile.name || "avatars.zip");
form.append("with_content", String(opts.withContent));
form.append("dry_run", String(opts.dryRun));
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<void> {
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<void> {
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<void> {
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<void> {
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;
}
export interface AdminAuditMessageItem {
id: number;
room_id: number;
room_name: string;
room_type: string;
content: string;
recalled: boolean;
deleted: boolean;
created_at: string;
deleted_at?: string | null;
}
export interface AdminAuditListResponse<T> {
items: T[];
total: number;
page: number;
}
export async function fetchAdminUserAudit(
id: number,
cookieHeader?: string
): Promise<AdminUserAuditProfile> {
const res = await fetch(
`${API_BASE}/api/admin/users/${id}`,
ssrInit(cookieHeaders(cookieHeader))
);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取用户档案失败");
return data as AdminUserAuditProfile;
}
export async function fetchAdminUserAuditPosts(
id: number,
page = 1,
cookieHeader?: string
): Promise<AdminAuditListResponse<AdminAuditPostItem>> {
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<AdminAuditPostItem>;
}
export async function apiAdminUserAudit(id: number): Promise<AdminUserAuditProfile> {
const res = await fetchWithRefresh(`/api/admin/users/${id}`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取用户档案失败");
return data as AdminUserAuditProfile;
}
export async function apiAdminUserAuditPosts(
id: number,
page = 1
): Promise<AdminAuditListResponse<AdminAuditPostItem>> {
const res = await fetchWithRefresh(`/api/admin/users/${id}/posts?page=${page}`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取帖子失败");
return data as AdminAuditListResponse<AdminAuditPostItem>;
}
export async function apiAdminUserAuditComments(
id: number,
page = 1
): Promise<AdminAuditListResponse<AdminAuditCommentItem>> {
const res = await fetchWithRefresh(`/api/admin/users/${id}/comments?page=${page}`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取评论失败");
return data as AdminAuditListResponse<AdminAuditCommentItem>;
}
export async function apiAdminUserAuditMessages(
id: number,
page = 1
): Promise<AdminAuditListResponse<AdminAuditMessageItem>> {
const res = await fetchWithRefresh(`/api/admin/users/${id}/messages?page=${page}`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取消息失败");
return data as AdminAuditListResponse<AdminAuditMessageItem>;
}
// ===== 站点外观设置 =====
export async function apiGetSettings(): Promise<PublicSettings> {
// 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;
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<PublicSettings> {
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<PublicSettings>);
}
/** 上传品牌图,只落盘返回 URL,不写入站点设置 */
export async function apiUploadBrand(slot: BrandSlot, file: File): Promise<string> {
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<string> {
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<string> {
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<string> {
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;
}
// ===== 板块(客户端,供角色授权弹窗勾选) =====
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<PendingCounts> {
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<void> {
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;
board: Board;
user: User;
}
export async function apiAdminContentCounts(): Promise<AdminContentCounts> {
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<string, unknown>
): Promise<void> {
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<AdminContentCounts> {
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;
created_at: string;
updated_at: string;
user: User;
}
// 群消息(发送者已 Preload)
export interface ChatMessage {
id: number;
room_id: number;
sender_id: number;
content: string;
reply_to_id?: number;
reply_snap?: string;
created_at: string;
recalled_at?: string | null;
recalled_by?: number;
sender: User;
/** 撤回操作者(已撤回时后端 Preload;本人撤回时通常等于 sender) */
recaller?: User | null;
}
// 单群未读明细
export interface UnreadRoom {
room_id: number;
count: number;
}
export interface ChatRoomsResponse {
rooms: ChatRoomView[];
total: number;
page: number;
size: number;
}
export interface ChatMembersResponse {
members: ChatRoomMember[];
}
export interface ChatMessagesResponse {
messages: ChatMessage[];
has_more: boolean;
}
export interface ChatUnreadSummaryResponse {
total: number;
rooms: UnreadRoom[];
}
// SSR 消息会话列表(默认仅已加入)
export async function fetchChatRooms(
page = 1,
size = 40,
q = "",
cookieHeader?: string,
scope: "joined" | "all" = "joined"
): Promise<ChatRoomsResponse> {
const params = new URLSearchParams({
page: String(page),
size: String(size),
scope,
});
if (q) params.set("q", q);
const res = await fetch(
`${API_BASE}/api/chat/rooms?${params}`,
ssrInit(cookieHeaders(cookieHeader))
);
if (res.status === 401) throw new Error("未登录");
if (!res.ok) throw new Error("获取会话列表失败");
return res.json();
}
// SSR 群详情:公开非成员 200(joined=false);私密非成员/不存在 404
export async function fetchChatRoom(
id: string,
cookieHeader?: string
): Promise<{ room: ChatRoomView }> {
const res = await fetch(
`${API_BASE}/api/chat/rooms/${id}`,
ssrInit(cookieHeaders(cookieHeader))
);
if (res.status === 401) throw new Error("未登录");
if (res.status === 404) throw new Error("群不存在");
if (!res.ok) throw new Error("获取群详情失败");
return res.json();
}
// SSR 历史消息游标分页(成员可见;非成员抛错由页面层处理)
export async function fetchChatMessages(
id: string,
before = 0,
size = 30,
cookieHeader?: string
): Promise<ChatMessagesResponse> {
const params = new URLSearchParams({ size: String(size) });
if (before) params.set("before_id", String(before));
const res = await fetch(
`${API_BASE}/api/chat/rooms/${id}/messages?${params}`,
ssrInit(cookieHeaders(cookieHeader))
);
if (res.status === 401) throw new Error("未登录");
if (res.status === 403) throw new Error("无权查看消息");
if (res.status === 404) throw new Error("群不存在");
if (!res.ok) throw new Error("获取消息失败");
return res.json();
}
// 客户端:会话列表
export async function apiListChatRooms(
page = 1,
size = 40,
q = "",
scope: "joined" | "all" = "joined"
): Promise<ChatRoomsResponse> {
const params = new URLSearchParams({
page: String(page),
size: String(size),
scope,
});
if (q) params.set("q", q);
const res = await fetchWithRefresh(`/api/chat/rooms?${params}`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取会话列表失败");
return data as ChatRoomsResponse;
}
// 客户端:打开/创建私聊
export async function apiOpenDirectChat(
userId: number
): Promise<{ room: ChatRoomView }> {
const res = await fetchWithRefresh("/api/chat/direct", {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ user_id: userId }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "无法发起私聊");
return data as { room: ChatRoomView };
}
// 客户端:公开用户资料(资料卡用;只取第一页减小体积)
export async function apiGetUserProfile(id: number): Promise<UserProfile> {
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<ChatMembersResponse> {
const res = await fetchWithRefresh(`/api/chat/rooms/${id}/members`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取成员名单失败");
return data as ChatMembersResponse;
}
// 客户端:群主/群管踢人
export async function apiKickChatMember(
roomId: number,
userId: number
): Promise<{ ok: boolean }> {
const res = await fetchWithRefresh(
`/api/chat/rooms/${roomId}/members/${userId}`,
{ method: "DELETE", headers: clientHeaders() }
);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "踢人失败");
return data as { ok: boolean };
}
// 客户端:禁言/解禁
export async function apiMuteChatMember(
roomId: number,
userId: number,
muted: boolean
): Promise<{ ok: boolean; muted: boolean }> {
const res = await fetchWithRefresh(
`/api/chat/rooms/${roomId}/members/${userId}/mute`,
{
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ muted }),
}
);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "禁言操作失败");
return data as { ok: boolean; muted: boolean };
}
// 客户端:站长任命/撤销群管理员
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<ChatMessagesResponse> {
const params = new URLSearchParams({ size: String(size) });
if (before) params.set("before_id", String(before));
const res = await fetchWithRefresh(`/api/chat/rooms/${id}/messages?${params}`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取消息失败");
return data as ChatMessagesResponse;
}
// 客户端:发消息(可带引用;@ 提醒由后端解析)
export async function apiSendChatMessage(
id: number,
content: string,
replyToId = 0
): Promise<{ message: ChatMessage }> {
const res = await fetchWithRefresh(`/api/chat/rooms/${id}/messages`, {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ content, reply_to_id: replyToId || undefined }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "发送失败");
return data as { message: ChatMessage };
}
// 客户端:推进已读水位(messageId=0 或超过最新则取最新)
export async function apiMarkChatRead(
id: number,
messageId = 0
): Promise<{ ok: boolean }> {
const res = await fetchWithRefresh(`/api/chat/rooms/${id}/read`, {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ message_id: messageId }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "标记已读失败");
return data as { ok: boolean };
}
// 客户端:全部群未读明细与总数(Header 红点对账)
export async function apiChatUnreadSummary(): Promise<ChatUnreadSummaryResponse> {
const res = await fetchWithRefresh("/api/chat/unread-summary", {
headers: clientHeaders(),
});
if (!res.ok) return { total: 0, rooms: [] };
return res.json();
}
export async function apiOperations<T = Record<string, unknown>>(path: string, method = "GET", body?: unknown): Promise<T> {
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;
}
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<AdConfig> {
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<string, unknown>): 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<AdConfig> {
return apiOperations<AdConfig>("/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<AdConfig> {
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<SponsorsConfig> {
return apiOperations<SponsorsConfig>("/api/admin/sponsors/config", "PUT", cfg);
}
export async function fetchAdminSponsorsConfig(cookie?: string): Promise<SponsorsConfig> {
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<SidebarPublicPayload> {
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 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<SidebarWidgetRecord, "name" | "html" | "css" | "js">
): Promise<{ widget: SidebarWidgetRecord }> {
return apiOperations("/api/admin/sidebar/widgets", "POST", body);
}
export async function apiAdminUpdateSidebarWidget(
id: number,
body: Pick<SidebarWidgetRecord, "name" | "html" | "css" | "js" | "enabled">
): 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");
}