Files
jiang13-bbs/frontend/lib/api.ts
freefire 3e55b5d230 feat: 管理端板块管理与旧版数据导入,补充部署运营文档
- 新增管理端板块管理页面与后端接口(admin_board)
- 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具
- 聊天用户卡片、板块图标等 UI 组件与界面优化
- 补充 about/公告/1Panel 部署等文档
- gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
2026-09-24 03:37:44 +08:00

3454 lines
103 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 { 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 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;
}
// /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;
}
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;
status: string;
like_count: number;
view_count: number;
comment_count: number;
liked: boolean;
created_at: string;
updated_at: string;
board: Board;
user: User;
content_locked?: boolean;
access_hint?: string;
attachments?: PostAttachment[];
question?: QuestionState | null;
poll?: PollState | null;
bounty?: BountyState | null;
lottery?: LotteryState | null;
/** 软删专用页标记 */
tombstone?: boolean;
delete_type?: string;
delete_reason?: string;
deleted_by?: number;
staff_deleted?: boolean;
deleted_at?: string;
}
export interface AcceptedAnswer {
id: number;
floor: number;
content: string;
created_at: string;
deleted?: boolean;
/** 管理删对公众省略 */
user?: {
id: number;
username: string;
nickname: string;
avatar: string;
};
}
export interface QuestionState {
solved: boolean;
accepted_comment_id: number;
accepted_floor: number;
accepted_answer?: AcceptedAnswer | null;
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;
};
stats: {
post_count: number;
comment_count: number;
points: number;
streak: number;
};
posts: PostListItem[];
posts_total: number;
page: number;
size: number;
}
export interface UserCommentItem {
id: number;
post_id: number;
post_title: string;
content: string;
created_at: string;
}
export interface UserCommentsResponse {
comments: UserCommentItem[];
total: number;
page: number;
size: number;
}
export interface NotificationItem {
id: number;
user_id: number;
actor_id: number;
type: "comment" | "reply" | "like" | "approved" | "rejected" | "mention" | "pending_review" | "deleted";
post_id: number;
comment_id: number;
room_id?: number;
message_id?: number;
content: string;
is_read: boolean;
created_at: string;
actor: User;
post?: { id: number; title: string };
room?: { id: number; name: string } | null;
}
export interface NotificationsResponse {
notifications: NotificationItem[];
total: number;
page: number;
size: number;
}
// 从 cookie 读取 CSRF token(j13_csrf 为非 HttpOnly,前端可读;生产名为 __Host- 前缀)
function getCSRFToken(): string {
if (typeof document === "undefined") return "";
const escaped = CSRF_COOKIE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]+)`));
return match ? decodeURIComponent(match[1]) : "";
}
// 构造客户端 fetch 的通用 headers(含 CSRF token)
function clientHeaders(extra: Record<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;
// 识别响应体中的封禁 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");
}
}
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();
}
// 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 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;
}
/** 与后端 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",
};
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",
};
}
// 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();
}
export async function fetchUserProfile(id: string, page = 1, cookieHeader?: string): Promise<UserProfile> {
const params = new URLSearchParams({ page: String(page), size: "20" });
const res = await fetch(`${API_BASE}/api/users/${id}?${params}`, ssrInit(cookieHeaders(cookieHeader)));
if (res.status === 404) throw new Error("用户不存在");
if (!res.ok) throw new Error("获取用户资料失败");
return res.json();
}
// SSR 端获取当前登录态(用户信息 + 未读通知数合并为一次请求),
// 供 layout 直出右上角用户区与铃铛红点,避免客户端水合后再切换
export async function fetchMe(cookieHeader?: string): Promise<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 };
}
}
export async function fetchUserComments(id: string, page = 1): Promise<UserCommentsResponse> {
const params = new URLSearchParams({ page: String(page), size: "20" });
const res = await fetch(`${API_BASE}/api/users/${id}/comments?${params}`, ssrInit());
if (res.status === 404) throw new Error("用户不存在");
if (!res.ok) throw new Error("获取评论失败");
const data = (await res.json()) as UserCommentsResponse;
// Go 空切片曾序列化为 null;统一归一成 [],避免 SSR 读 .length 崩
return { ...data, comments: data.comments ?? [] };
}
// SSR 直出通知列表(仅登录用户;401 由页面层转为登录引导)
export async function fetchNotifications(page = 1, cookieHeader?: string): Promise<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();
// /me 以 200 携带封禁标记(Header 挂载静默校正/F5 后的主识别路径)
if (data.banned === true && !bannedNotified) {
bannedNotified = true;
emitForceLogout("banned");
}
return data;
}
export async function apiLogin(username: string, password: string) {
const res = await fetch("/api/login", {
method: "POST",
credentials: "include",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ username, password }),
});
const data = await res.json();
// 新登录态开始:复位封禁通知去重(解封后再次被封仍可提示)
if (res.ok && data?.user) bannedNotified = false;
return data;
}
export async function apiRegister(username: string, email: string, password: string, 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(),
});
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(): Promise<void> {
try {
await fetchWithRefresh("/api/telemetry/pageview", {
method: "POST",
headers: clientHeaders(),
});
} catch {
/* 埋点失败不影响浏览 */
}
}
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 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 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 };
}
/** 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;
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 };
}
// 某用户的登录历史(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;
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;
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 apiAdminCommentHistory(
commentId: number,
page = 1,
size = 10
): Promise<{ items: CommentEditHistoryItem[]; total: number; page: number; size: number }> {
const params = new URLSearchParams({ page: String(page), size: String(size) });
const res = await fetchWithRefresh(
`/api/admin/content/comments/${commentId}/history?${params}`,
{ headers: clientHeaders() }
);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取编辑历史失败");
return data;
}
export async function fetchAdminContentCounts(
cookieHeader?: string
): Promise<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();
}