Files
jiang13-bbs/frontend/lib/api.ts
freefire 99c0f0319d feat: 品牌裁剪弹窗/Markdown 行内短代码与详情视频块等
新增:
- BrandCropModal 品牌图裁剪弹窗 + cropImage 工具
- Markdown 行内短代码(inlineShortcodes + remark 插件)
- MdDetails 折叠块、MdVideo 视频块组件
- directiveBlocks/inlineShortcodes 测试

其它:
- 聊天后端(handler/service chat)调整
- Markdown 渲染链路、编辑器、全局样式与品牌相关面板更新
2026-10-02 01:40:06 +08:00

4983 lines
155 KiB
TypeScript
Raw Blame History

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