后台可注入全站 CSS/JS(SSR 生效,CSS 可热换);发帖/编辑改为锁一屏工作室;经济看板增加存量健康度;主题令牌与弹层误关一并收紧。 Co-authored-by: Cursor <cursoragent@cursor.com>
2781 lines
82 KiB
TypeScript
2781 lines
82 KiB
TypeScript
import { CSRF_COOKIE } from "./cookies";
|
||
import { emitForceLogout } from "./userEvents";
|
||
|
||
// API 基础配置
|
||
// 注意:客户端请求使用相对路径 /api/*,走 Next.js rewrite 代理到后端,
|
||
// 这样浏览器视为同源,Cookie 自动携带,无需处理 CORS。
|
||
// SSR/middleware 直连后端:优先服务端专用 BACKEND_URL(边缘部署必须显式配置,
|
||
// localhost 兜底只在本地开发有效),回退到 NEXT_PUBLIC_API_URL。
|
||
const API_BASE =
|
||
process.env.BACKEND_URL || process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||
|
||
// SSR 请求超时:后端不可用时快速降级为游客视图,不阻塞页面渲染
|
||
const SSR_TIMEOUT_MS = 8000;
|
||
|
||
function ssrInit(headers?: HeadersInit): RequestInit {
|
||
return {
|
||
cache: "no-store",
|
||
headers,
|
||
signal: AbortSignal.timeout(SSR_TIMEOUT_MS),
|
||
};
|
||
}
|
||
|
||
// 类型定义
|
||
export interface User {
|
||
id: number;
|
||
username: string;
|
||
nickname: string;
|
||
avatar: string;
|
||
role: string;
|
||
// 板块管理员被授权的板块(仅 /api/me 返回;前端据此渲染审核入口/按钮)
|
||
board_ids?: number[];
|
||
// 仅 /api/me 与 /api/profile 响应中返回(用户自身敏感信息)
|
||
email?: string;
|
||
signature?: string;
|
||
}
|
||
|
||
// /api/me 响应:用户信息 + 未读通知数(SSR layout 一次请求直出)
|
||
export interface MeResponse {
|
||
user: User | null;
|
||
unread_count: number;
|
||
// 群聊未读消息总数(二期)
|
||
chat_unread_count?: number;
|
||
// 后端 OptionalAuth 识别到凭据所属账号已封禁时携带(HTTP 仍 200,不阻断页面)
|
||
banned?: boolean;
|
||
code?: string;
|
||
}
|
||
|
||
export interface Board {
|
||
id: number;
|
||
name: string;
|
||
description: string;
|
||
icon: string;
|
||
color_index?: number;
|
||
sort_order: number;
|
||
}
|
||
|
||
// 首页聚合接口
|
||
export interface OverviewStats {
|
||
posts: number;
|
||
users: number;
|
||
comments: number;
|
||
today_posts: number;
|
||
today_users: number;
|
||
today_comments: number;
|
||
online: number;
|
||
peak_online: number;
|
||
}
|
||
|
||
export interface ActiveUser {
|
||
id: number;
|
||
username: string;
|
||
nickname: string;
|
||
avatar: string;
|
||
post_count: number;
|
||
comment_count: number;
|
||
}
|
||
|
||
export interface BoardWithCount extends Board {
|
||
post_count: number;
|
||
}
|
||
|
||
// 公告标签预设色(与后端白名单一致)
|
||
export type AnnouncementColor =
|
||
| "blue"
|
||
| "green"
|
||
| "orange"
|
||
| "red"
|
||
| "purple"
|
||
| "gray"
|
||
| "teal"
|
||
| "cyan"
|
||
| "pink"
|
||
| "amber"
|
||
| "indigo"
|
||
| "rose";
|
||
|
||
// 右栏站点公告摘要(标签/日期/标题)
|
||
export interface AnnouncementSummary {
|
||
id: number;
|
||
title: string;
|
||
tag: string;
|
||
tag_color: AnnouncementColor;
|
||
created_at: string;
|
||
pinned?: boolean;
|
||
}
|
||
|
||
// 公告详情/后台管理完整对象
|
||
export interface Announcement {
|
||
id: number;
|
||
title: string;
|
||
content: string;
|
||
tag: string;
|
||
tag_color: AnnouncementColor;
|
||
published: boolean;
|
||
pinned: boolean;
|
||
created_at: string;
|
||
updated_at: string;
|
||
}
|
||
|
||
export interface SidebarPageItem {
|
||
id: number;
|
||
slug: string;
|
||
title: string;
|
||
excerpt: string;
|
||
}
|
||
|
||
/** 公开单页目录(/about 全部页) */
|
||
export interface SitePageSummary {
|
||
id: number;
|
||
slug: string;
|
||
title: string;
|
||
excerpt: string;
|
||
created_at: string;
|
||
updated_at: string;
|
||
}
|
||
|
||
export interface SitePage {
|
||
id: number;
|
||
slug: string;
|
||
title: string;
|
||
content: string;
|
||
excerpt: string;
|
||
published: boolean;
|
||
show_in_sidebar: boolean;
|
||
sort_order: number;
|
||
allow_comments: boolean;
|
||
created_at: string;
|
||
updated_at: string;
|
||
}
|
||
|
||
export interface SitePageInput {
|
||
slug: string;
|
||
title: string;
|
||
content: string;
|
||
excerpt?: string;
|
||
published: boolean;
|
||
show_in_sidebar: boolean;
|
||
sort_order?: number;
|
||
}
|
||
|
||
export interface NewUser {
|
||
id: number;
|
||
username: string;
|
||
nickname: string;
|
||
avatar: string;
|
||
created_at: string;
|
||
}
|
||
|
||
export interface CheckinStatus {
|
||
checked_today: boolean;
|
||
streak: number;
|
||
total_points: number;
|
||
today_points: number;
|
||
}
|
||
|
||
export interface OverviewResponse {
|
||
stats: OverviewStats;
|
||
hot: PostListItem[];
|
||
active_users: ActiveUser[];
|
||
boards: BoardWithCount[];
|
||
announcements: AnnouncementSummary[];
|
||
announcements_total?: number;
|
||
sidebar_pages?: SidebarPageItem[];
|
||
new_users: NewUser[];
|
||
checkin?: CheckinStatus | null;
|
||
}
|
||
|
||
/** 板块右栏:管理员公开资料 */
|
||
export interface BoardModerator {
|
||
id: number;
|
||
username: string;
|
||
nickname: string;
|
||
avatar: string;
|
||
}
|
||
|
||
/** 板块右栏:板级统计 */
|
||
export interface BoardSidebarStats {
|
||
post_count: number;
|
||
today_posts: number;
|
||
comment_count: number;
|
||
today_comments: number;
|
||
}
|
||
|
||
/** 首页选中板块后的右栏聚合 */
|
||
export interface BoardSidebarResponse {
|
||
board: BoardWithCount;
|
||
moderators: BoardModerator[];
|
||
stats: BoardSidebarStats;
|
||
hot: PostListItem[];
|
||
active_users: ActiveUser[];
|
||
checkin?: CheckinStatus | null;
|
||
}
|
||
|
||
export interface PostListItem {
|
||
id: number;
|
||
board_id: number;
|
||
user_id: number;
|
||
title: string;
|
||
tags: string;
|
||
post_type: string;
|
||
type_status?: string;
|
||
content_access?: string;
|
||
access_points?: number;
|
||
pinned: number;
|
||
recommended: boolean;
|
||
like_count: number;
|
||
view_count: number;
|
||
comment_count: number;
|
||
status?: string;
|
||
liked: boolean;
|
||
created_at: string;
|
||
last_reply?: {
|
||
user: Pick<User, "id" | "username" | "nickname" | "avatar">;
|
||
created_at: string;
|
||
} | null;
|
||
board: Board;
|
||
user: User;
|
||
}
|
||
|
||
export interface PostAttachment {
|
||
id: number;
|
||
name: string;
|
||
size: number;
|
||
mime: string;
|
||
price_points: number;
|
||
download_count: number;
|
||
unlocked: boolean;
|
||
}
|
||
|
||
export interface Post {
|
||
id: number;
|
||
board_id: number;
|
||
user_id: number;
|
||
title: string;
|
||
content: string;
|
||
tags: string;
|
||
post_type: string;
|
||
content_access?: string;
|
||
access_points?: number;
|
||
type_meta?: string;
|
||
type_status?: string;
|
||
pinned: number;
|
||
recommended: boolean;
|
||
status: string;
|
||
like_count: number;
|
||
view_count: number;
|
||
comment_count: number;
|
||
liked: boolean;
|
||
created_at: string;
|
||
updated_at: string;
|
||
board: Board;
|
||
user: User;
|
||
content_locked?: boolean;
|
||
access_hint?: string;
|
||
attachments?: PostAttachment[];
|
||
question?: QuestionState | null;
|
||
poll?: PollState | null;
|
||
bounty?: BountyState | null;
|
||
lottery?: LotteryState | null;
|
||
/** 软删专用页标记 */
|
||
tombstone?: boolean;
|
||
delete_type?: string;
|
||
delete_reason?: string;
|
||
deleted_by?: number;
|
||
staff_deleted?: boolean;
|
||
deleted_at?: string;
|
||
}
|
||
|
||
export interface AcceptedAnswer {
|
||
id: number;
|
||
floor: number;
|
||
content: string;
|
||
created_at: string;
|
||
deleted?: boolean;
|
||
/** 管理删对公众省略 */
|
||
user?: {
|
||
id: number;
|
||
username: string;
|
||
nickname: string;
|
||
avatar: string;
|
||
};
|
||
}
|
||
|
||
export interface QuestionState {
|
||
solved: boolean;
|
||
accepted_comment_id: number;
|
||
accepted_floor: number;
|
||
accepted_answer?: AcceptedAnswer | null;
|
||
solved_at?: string;
|
||
can_accept: boolean;
|
||
can_solve: boolean;
|
||
can_reopen: boolean;
|
||
}
|
||
|
||
export interface PollVoter {
|
||
id: number;
|
||
nickname: string;
|
||
username: string;
|
||
avatar: string;
|
||
}
|
||
|
||
export interface PollOptionStat {
|
||
index: number;
|
||
text: string;
|
||
votes: number;
|
||
percent: number;
|
||
voters?: PollVoter[];
|
||
}
|
||
|
||
export interface PollState {
|
||
options: PollOptionStat[];
|
||
multi: boolean;
|
||
closed: boolean;
|
||
anonymous: boolean;
|
||
ends_at?: string;
|
||
closed_at?: string;
|
||
total_votes: number;
|
||
voters: number;
|
||
my_options: number[];
|
||
can_vote: boolean;
|
||
can_close: boolean;
|
||
can_edit_options: boolean;
|
||
}
|
||
|
||
export interface BountyState {
|
||
points: number;
|
||
accepted_comment_id: number;
|
||
accepted_floor: number;
|
||
accepted_answer?: AcceptedAnswer | null;
|
||
escrowed: boolean;
|
||
refunded: boolean;
|
||
expired?: boolean;
|
||
ends_at?: string;
|
||
settled: boolean;
|
||
can_accept: boolean;
|
||
}
|
||
|
||
export interface LotteryPrize {
|
||
name: string;
|
||
count: number;
|
||
}
|
||
|
||
export interface LotteryEntrant {
|
||
id: number;
|
||
nickname: string;
|
||
username: string;
|
||
avatar: string;
|
||
/** 中奖楼层评论 ID(该用户最早主评,每人仅一条) */
|
||
comment_id?: number;
|
||
floor?: number;
|
||
prize_name?: string;
|
||
}
|
||
|
||
export interface LotteryState {
|
||
slots: number;
|
||
prizes: LotteryPrize[];
|
||
drawn: boolean;
|
||
closed: boolean;
|
||
ends_at?: string;
|
||
entry_count: number;
|
||
eligible?: boolean;
|
||
/** 新号入池冷静期小时数(与发帖冷静期同源,0=关闭) */
|
||
cooldown_hours?: number;
|
||
can_draw: boolean;
|
||
can_edit_prizes?: boolean;
|
||
winners: LotteryEntrant[];
|
||
}
|
||
|
||
export interface Comment {
|
||
id: number;
|
||
post_id: number;
|
||
/** 管理删对公众省略 */
|
||
user_id?: number;
|
||
parent_id?: number | null; // 父评论 ID,null = 主评论(楼层)
|
||
root_id?: number | null; // 所属楼层 ID,子评论必填
|
||
depth?: number; // 层级:主评论 0,子评论 = 父 + 1
|
||
content: string;
|
||
status: string;
|
||
created_at: string;
|
||
updated_at?: string;
|
||
/** 相对创建已编辑(服务端判定) */
|
||
edited?: boolean;
|
||
/** 管理删对公众省略;版主与评论者本人仍返回 */
|
||
user?: User;
|
||
/** 软删占位 */
|
||
deleted?: boolean;
|
||
/** 管理删除(DeletedBy≠评论者) */
|
||
staff_deleted?: boolean;
|
||
delete_type?: string;
|
||
delete_reason?: string;
|
||
deleted_by?: number;
|
||
/** 管理删执行者(公开,用于主页快捷入口) */
|
||
deleted_by_user?: {
|
||
id: number;
|
||
username: string;
|
||
nickname: string;
|
||
avatar: string;
|
||
};
|
||
}
|
||
|
||
// 评论树节点:主评论为根,replies 为其全部子回复(时间正序,全量 SSR 直出)
|
||
export interface CommentNode extends Comment {
|
||
replies?: CommentNode[];
|
||
}
|
||
|
||
// 帖子评论楼层分页响应(时间正序;楼层号 = (page-1)*size + 序号 + 1,仅主评论占楼层)
|
||
export interface CommentsResponse {
|
||
comments: CommentNode[];
|
||
total: number; // 楼层数(主评论数)→ 分页与楼层号计算
|
||
total_comments: number; // 全部评论数(含回复)→ 头部徽标
|
||
page: number;
|
||
size: number;
|
||
}
|
||
|
||
export interface PostsResponse {
|
||
posts: PostListItem[];
|
||
total: number;
|
||
page: number;
|
||
size: number;
|
||
}
|
||
|
||
export interface UserProfile {
|
||
user: {
|
||
id: number;
|
||
username: string;
|
||
nickname: string;
|
||
avatar: string;
|
||
signature: string;
|
||
role: string;
|
||
board_ids?: number[];
|
||
created_at: string;
|
||
/** 仅超管/站长访客可见(公开资料不下发) */
|
||
banned?: boolean;
|
||
};
|
||
stats: {
|
||
post_count: number;
|
||
comment_count: number;
|
||
points: number;
|
||
streak: number;
|
||
};
|
||
posts: PostListItem[];
|
||
posts_total: number;
|
||
page: number;
|
||
size: number;
|
||
}
|
||
|
||
export interface UserCommentItem {
|
||
id: number;
|
||
post_id: number;
|
||
post_title: string;
|
||
content: string;
|
||
created_at: string;
|
||
}
|
||
|
||
export interface UserCommentsResponse {
|
||
comments: UserCommentItem[];
|
||
total: number;
|
||
page: number;
|
||
size: number;
|
||
}
|
||
|
||
export interface NotificationItem {
|
||
id: number;
|
||
user_id: number;
|
||
actor_id: number;
|
||
type: "comment" | "reply" | "like" | "approved" | "rejected" | "mention" | "pending_review" | "deleted";
|
||
post_id: number;
|
||
comment_id: number;
|
||
room_id?: number;
|
||
message_id?: number;
|
||
content: string;
|
||
is_read: boolean;
|
||
created_at: string;
|
||
actor: User;
|
||
post?: { id: number; title: string };
|
||
room?: { id: number; name: string } | null;
|
||
}
|
||
|
||
export interface NotificationsResponse {
|
||
notifications: NotificationItem[];
|
||
total: number;
|
||
page: number;
|
||
size: number;
|
||
}
|
||
|
||
// 从 cookie 读取 CSRF token(j13_csrf 为非 HttpOnly,前端可读;生产名为 __Host- 前缀)
|
||
function getCSRFToken(): string {
|
||
if (typeof document === "undefined") return "";
|
||
const escaped = CSRF_COOKIE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]+)`));
|
||
return match ? decodeURIComponent(match[1]) : "";
|
||
}
|
||
|
||
// 构造客户端 fetch 的通用 headers(含 CSRF token)
|
||
function clientHeaders(extra: Record<string, string> = {}): HeadersInit {
|
||
const headers: Record<string, string> = { ...extra };
|
||
const csrf = getCSRFToken();
|
||
if (csrf) headers["X-CSRF-Token"] = csrf;
|
||
return headers;
|
||
}
|
||
|
||
// ===== Refresh token 自动续期 =====
|
||
|
||
let refreshPromise: Promise<{ ok: boolean; banned: boolean }> | null = null;
|
||
|
||
// 被封禁通知去重:同一登录生命周期内只强制下线/弹一次,
|
||
// 重新登录成功(apiLogin)后复位,使"解封后再次被封"仍能再次提示
|
||
let bannedNotified = false;
|
||
|
||
// 识别响应体中的封禁 code;body 只能读一次,故 clone 探测,原响应照常交给调用方
|
||
function detectBannedBody(res: Response): void {
|
||
if (res.status !== 403 || bannedNotified) return;
|
||
res
|
||
.clone()
|
||
.json()
|
||
.then((data: { code?: string }) => {
|
||
if (data?.code === "account_banned" && !bannedNotified) {
|
||
bannedNotified = true;
|
||
emitForceLogout("banned");
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
// 调用 /api/auth/refresh 刷新 access token(refresh cookie 由浏览器自动携带)
|
||
async function apiRefresh(): Promise<{ ok: boolean; banned: boolean }> {
|
||
try {
|
||
const res = await fetch("/api/auth/refresh", {
|
||
method: "POST",
|
||
credentials: "include",
|
||
headers: clientHeaders(),
|
||
});
|
||
if (res.ok) return { ok: true, banned: false };
|
||
if (res.status === 403) {
|
||
const data = await res.json().catch(() => ({} as { code?: string }));
|
||
if (data.code === "account_banned") return { ok: false, banned: true };
|
||
}
|
||
return { ok: false, banned: false };
|
||
} catch {
|
||
return { ok: false, banned: false };
|
||
}
|
||
}
|
||
|
||
// 带自动续期的 fetch:遇到 401 时尝试 refresh,成功后重试原请求;
|
||
// 任意环节识别到账号封禁,派发全局强制下线事件(只派一次)
|
||
async function fetchWithRefresh(url: string, init: RequestInit): Promise<Response> {
|
||
let res = await fetch(url, { ...init, credentials: "include" });
|
||
detectBannedBody(res);
|
||
|
||
if (res.status === 401) {
|
||
// 串行化 refresh:多个并发 401 只触发一次 refresh
|
||
if (!refreshPromise) {
|
||
refreshPromise = apiRefresh().finally(() => {
|
||
refreshPromise = null;
|
||
});
|
||
}
|
||
const result = await refreshPromise;
|
||
if (result.ok) {
|
||
// refresh 成功,重试原请求(CSRF cookie 可能已更新)
|
||
const newHeaders = clientHeaders();
|
||
const mergedInit = { ...init, credentials: "include" as const, headers: newHeaders };
|
||
res = await fetch(url, mergedInit);
|
||
detectBannedBody(res);
|
||
} else if (result.banned && !bannedNotified) {
|
||
bannedNotified = true;
|
||
emitForceLogout("banned");
|
||
}
|
||
}
|
||
return res;
|
||
}
|
||
|
||
// ===== SSR 端 fetch(公开接口,直接请求后端) =====
|
||
|
||
// SSR 页面通过 (await cookies()).toString() 传入,后端据此识别登录用户并填充 liked 状态
|
||
function cookieHeaders(cookieHeader?: string): HeadersInit | undefined {
|
||
return cookieHeader ? { Cookie: cookieHeader } : undefined;
|
||
}
|
||
|
||
export async function fetchPosts(
|
||
page = 1,
|
||
size = 20,
|
||
boardId?: number,
|
||
sort = "latest", // latest | recommended | new
|
||
keyword = "",
|
||
recommended = false,
|
||
cookieHeader?: string
|
||
): Promise<PostsResponse> {
|
||
const params = new URLSearchParams({ page: String(page), size: String(size), sort });
|
||
if (boardId) params.set("board_id", String(boardId));
|
||
if (keyword) params.set("keyword", keyword);
|
||
if (recommended) params.set("recommended", "true");
|
||
const res = await fetch(`${API_BASE}/api/posts?${params}`, ssrInit(cookieHeaders(cookieHeader)));
|
||
if (!res.ok) throw new Error("获取帖子失败");
|
||
return res.json();
|
||
}
|
||
|
||
// SSR 首页聚合数据;失败时抛错,由首页调用方 try/catch 降级为无侧栏模块
|
||
export async function fetchOverview(cookieHeader?: string): Promise<OverviewResponse> {
|
||
const res = await fetch(`${API_BASE}/api/overview`, ssrInit(cookieHeaders(cookieHeader)));
|
||
if (!res.ok) throw new Error("获取社区概览失败");
|
||
return res.json();
|
||
}
|
||
|
||
/** SSR 板块右栏;失败时抛错,由首页降级 */
|
||
export async function fetchBoardSidebar(
|
||
boardId: number,
|
||
cookieHeader?: string
|
||
): Promise<BoardSidebarResponse> {
|
||
const res = await fetch(
|
||
`${API_BASE}/api/boards/${boardId}/sidebar`,
|
||
ssrInit(cookieHeaders(cookieHeader))
|
||
);
|
||
if (!res.ok) throw new Error("获取板块侧栏失败");
|
||
return res.json();
|
||
}
|
||
|
||
export async function fetchBoards(): Promise<{ boards: BoardWithCount[] }> {
|
||
const res = await fetch(`${API_BASE}/api/boards`, ssrInit());
|
||
if (!res.ok) throw new Error("获取板块失败");
|
||
return res.json();
|
||
}
|
||
|
||
// 公开站点设置:accent 为站点主题色 hex(空串=内置默认);
|
||
// trust_reviewed_publish 为已过审用户后续免审(缺省/未配置视为 true)
|
||
export interface PublicSettings {
|
||
accent: string;
|
||
trust_reviewed_publish: boolean;
|
||
site_name: string;
|
||
site_description: string;
|
||
allow_register: boolean;
|
||
/** 全站是否开放评论;关闭后前台不展示评论能力 */
|
||
allow_comments: 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;
|
||
/** 全站自定义 CSS;空串=无 */
|
||
custom_css: string;
|
||
/** 全站自定义 JS;空串=无(保存后整页加载生效) */
|
||
custom_js: string;
|
||
}
|
||
|
||
/** 与后端 DefaultAttachmentExts 保持同步 */
|
||
export const DEFAULT_ATTACHMENT_EXTS: string[] = [
|
||
"pdf", "txt", "md", "csv", "json",
|
||
"doc", "docx", "xls", "xlsx", "ppt", "pptx",
|
||
"zip", "7z", "rar", "tar", "gz", "tgz",
|
||
"jpg", "jpeg", "png", "webp", "gif",
|
||
"mp3", "wav", "ogg", "mp4", "webm",
|
||
"exe", "msi", "msp", "apk", "dmg", "iso", "deb", "rpm", "dll",
|
||
"html", "htm", "js", "mjs", "css", "svg", "xml", "bat", "cmd", "ps1", "sh",
|
||
];
|
||
|
||
const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
|
||
accent: "",
|
||
trust_reviewed_publish: true,
|
||
site_name: "姜十三论坛",
|
||
site_description: "姜十三论坛 - 技术分享与讨论社区",
|
||
allow_register: true,
|
||
allow_comments: true,
|
||
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,
|
||
custom_css: "",
|
||
custom_js: "",
|
||
};
|
||
|
||
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,
|
||
site_name: (data?.site_name || "").trim() || DEFAULT_PUBLIC_SETTINGS.site_name,
|
||
site_description:
|
||
data?.site_description !== undefined
|
||
? String(data.site_description).trim()
|
||
: DEFAULT_PUBLIC_SETTINGS.site_description,
|
||
allow_register: data?.allow_register !== false,
|
||
allow_comments: data?.allow_comments !== false,
|
||
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,
|
||
custom_css: typeof data?.custom_css === "string" ? data.custom_css : "",
|
||
custom_js: typeof data?.custom_js === "string" ? data.custom_js : "",
|
||
};
|
||
}
|
||
|
||
// SSR 读取公开站点设置(主题色注入用);后端不可用时降级为默认色,不阻塞渲染
|
||
export async function fetchPublicSettings(): Promise<PublicSettings> {
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/settings`, ssrInit());
|
||
if (!res.ok) return { ...DEFAULT_PUBLIC_SETTINGS };
|
||
return normalizePublicSettings((await res.json()) as Partial<PublicSettings>);
|
||
} catch {
|
||
return { ...DEFAULT_PUBLIC_SETTINGS };
|
||
}
|
||
}
|
||
|
||
export async function fetchPostDetail(id: string, cookieHeader?: string): Promise<{ post: Post }> {
|
||
const res = await fetch(`${API_BASE}/api/posts/${id}`, ssrInit(cookieHeaders(cookieHeader)));
|
||
if (!res.ok) throw new Error("获取帖子失败");
|
||
return res.json();
|
||
}
|
||
|
||
export async function fetchComments(
|
||
postId: string,
|
||
page = 1,
|
||
cookieHeader?: string
|
||
): Promise<CommentsResponse> {
|
||
const params = new URLSearchParams({ page: String(page), size: "20" });
|
||
const res = await fetch(
|
||
`${API_BASE}/api/posts/${postId}/comments?${params}`,
|
||
ssrInit(cookieHeaders(cookieHeader))
|
||
);
|
||
if (!res.ok) throw new Error("获取评论失败");
|
||
return res.json();
|
||
}
|
||
|
||
export async function fetchUserProfile(id: string, page = 1, cookieHeader?: string): Promise<UserProfile> {
|
||
const params = new URLSearchParams({ page: String(page), size: "20" });
|
||
const res = await fetch(`${API_BASE}/api/users/${id}?${params}`, ssrInit(cookieHeaders(cookieHeader)));
|
||
if (res.status === 404) throw new Error("用户不存在");
|
||
if (!res.ok) throw new Error("获取用户资料失败");
|
||
return res.json();
|
||
}
|
||
|
||
// SSR 端获取当前登录态(用户信息 + 未读通知数合并为一次请求),
|
||
// 供 layout 直出右上角用户区与铃铛红点,避免客户端水合后再切换
|
||
export async function fetchMe(cookieHeader?: string): Promise<MeResponse> {
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/me`, ssrInit(cookieHeaders(cookieHeader)));
|
||
if (!res.ok) return { user: null, unread_count: 0 };
|
||
return res.json();
|
||
} catch {
|
||
// 超时/后端不可用:降级游客视图,不阻塞页面渲染
|
||
return { user: null, unread_count: 0 };
|
||
}
|
||
}
|
||
|
||
export async function fetchUserComments(id: string, page = 1): Promise<UserCommentsResponse> {
|
||
const params = new URLSearchParams({ page: String(page), size: "20" });
|
||
const res = await fetch(`${API_BASE}/api/users/${id}/comments?${params}`, ssrInit());
|
||
if (res.status === 404) throw new Error("用户不存在");
|
||
if (!res.ok) throw new Error("获取评论失败");
|
||
const data = (await res.json()) as UserCommentsResponse;
|
||
// Go 空切片曾序列化为 null;统一归一成 [],避免 SSR 读 .length 崩
|
||
return { ...data, comments: data.comments ?? [] };
|
||
}
|
||
|
||
// SSR 直出通知列表(仅登录用户;401 由页面层转为登录引导)
|
||
export async function fetchNotifications(page = 1, cookieHeader?: string): Promise<NotificationsResponse> {
|
||
const params = new URLSearchParams({ page: String(page), size: "20" });
|
||
const res = await fetch(`${API_BASE}/api/notifications?${params}`, ssrInit(cookieHeaders(cookieHeader)));
|
||
if (res.status === 401) throw new Error("未登录");
|
||
if (!res.ok) throw new Error("获取通知失败");
|
||
return res.json();
|
||
}
|
||
|
||
// ===== 客户端 API 调用(走 rewrite,携带 cookie + CSRF) =====
|
||
|
||
// 获取当前登录用户(依赖 OptionalAuth,未登录返回 { user: null, unread_count: 0 })
|
||
export async function apiMe(): Promise<MeResponse> {
|
||
const res = await fetch("/api/me", {
|
||
credentials: "include",
|
||
cache: "no-store",
|
||
});
|
||
const data: MeResponse = await res.json();
|
||
// /me 以 200 携带封禁标记(Header 挂载静默校正/F5 后的主识别路径)
|
||
if (data.banned === true && !bannedNotified) {
|
||
bannedNotified = true;
|
||
emitForceLogout("banned");
|
||
}
|
||
return data;
|
||
}
|
||
|
||
export async function apiLogin(username: string, password: string) {
|
||
const res = await fetch("/api/login", {
|
||
method: "POST",
|
||
credentials: "include",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify({ username, password }),
|
||
});
|
||
const data = await res.json();
|
||
// 新登录态开始:复位封禁通知去重(解封后再次被封仍可提示)
|
||
if (res.ok && data?.user) bannedNotified = false;
|
||
return data;
|
||
}
|
||
|
||
export async function apiRegister(username: string, email: string, password: string) {
|
||
const res = await fetch("/api/register", {
|
||
method: "POST",
|
||
credentials: "include",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify({ username, email, password }),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiLogout() {
|
||
const res = await fetchWithRefresh("/api/logout", {
|
||
method: "POST",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiCreatePost(data: {
|
||
board_id: number;
|
||
title: string;
|
||
content: string;
|
||
tags?: string;
|
||
post_type?: string;
|
||
type_meta?: string;
|
||
attachment_ids?: number[];
|
||
}) {
|
||
const res = await fetchWithRefresh("/api/posts", {
|
||
method: "POST",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify(data),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiCreateComment(postId: string, content: string, parentId?: number) {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}/comments`, {
|
||
method: "POST",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify(parentId ? { content, parent_id: parentId } : { content }),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiUpdatePost(
|
||
postId: string,
|
||
data: {
|
||
title?: string;
|
||
content?: string;
|
||
tags?: string;
|
||
type_meta?: string;
|
||
attachment_ids?: number[];
|
||
}
|
||
) {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}`, {
|
||
method: "PUT",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify(data),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiUnlockPost(postId: string) {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}/unlock`, {
|
||
method: "POST",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiUnlockPostPassword(postId: string, password: string) {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}/unlock-password`, {
|
||
method: "POST",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify({ password }),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiGetPoints(): Promise<{ points: number }> {
|
||
const res = await fetchWithRefresh("/api/points", {
|
||
method: "GET",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export interface LedgerParty {
|
||
id: number;
|
||
username: string;
|
||
nickname: string;
|
||
avatar: string;
|
||
}
|
||
|
||
export interface PointLedgerItem {
|
||
id: number;
|
||
delta: number;
|
||
balance: number;
|
||
reason: string;
|
||
ref_type: string;
|
||
ref_id: number;
|
||
note: string;
|
||
created_at: string;
|
||
user?: LedgerParty;
|
||
from?: LedgerParty;
|
||
to?: LedgerParty;
|
||
}
|
||
|
||
export async function apiGetPointsLedger(
|
||
page = 1,
|
||
size = 20
|
||
): Promise<{ items: PointLedgerItem[]; total: number; page: number; size: number; error?: string }> {
|
||
const res = await fetchWithRefresh(`/api/points/ledger?page=${page}&size=${size}`, {
|
||
method: "GET",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export interface EconomyStats {
|
||
total_balance: number;
|
||
ledger_count: number;
|
||
checkin_today: number;
|
||
open_bounties: number;
|
||
escrowed_points: number;
|
||
posts_by_type: Record<string, number>;
|
||
recent_ledger: PointLedgerItem[];
|
||
checkin_series?: { date: string; count: number }[];
|
||
ledger_by_reason?: { key: string; label: string; count: number }[];
|
||
delta_series?: { date: string; granted: number; spent: number }[];
|
||
net_delta_7d?: number;
|
||
}
|
||
|
||
export interface AnalyticsDayCount {
|
||
date: string;
|
||
count: number;
|
||
}
|
||
|
||
export interface AnalyticsVisitDay {
|
||
date: string;
|
||
pv: number;
|
||
uv: number;
|
||
}
|
||
|
||
export interface AnalyticsNamedCount {
|
||
key: string;
|
||
label: string;
|
||
count: number;
|
||
}
|
||
|
||
export interface AnalyticsTopViewPost {
|
||
id: number;
|
||
title: string;
|
||
view_count: number;
|
||
board_id: number;
|
||
}
|
||
|
||
export interface AnalyticsOverview {
|
||
range_days: number;
|
||
today_pv: number;
|
||
today_uv: number;
|
||
visits: AnalyticsVisitDay[];
|
||
posts_series: AnalyticsDayCount[];
|
||
comments_series: AnalyticsDayCount[];
|
||
registers_series: AnalyticsDayCount[];
|
||
boards: AnalyticsNamedCount[];
|
||
post_types: AnalyticsNamedCount[];
|
||
top_viewed: AnalyticsTopViewPost[];
|
||
}
|
||
|
||
export async function fetchAdminAnalytics(
|
||
cookieHeader?: string,
|
||
range: "7d" | "30d" = "7d"
|
||
): Promise<AnalyticsOverview> {
|
||
const res = await fetch(
|
||
`${API_BASE}/api/admin/analytics/overview?range=${range}`,
|
||
ssrInit(cookieHeaders(cookieHeader))
|
||
);
|
||
if (!res.ok) throw new Error("failed");
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiAdminAnalytics(range: "7d" | "30d" = "7d"): Promise<AnalyticsOverview> {
|
||
const res = await fetchWithRefresh(`/api/admin/analytics/overview?range=${range}`, {
|
||
method: "GET",
|
||
headers: clientHeaders(),
|
||
});
|
||
if (!res.ok) throw new Error("failed");
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiTelemetryPageView(): Promise<void> {
|
||
try {
|
||
await fetchWithRefresh("/api/telemetry/pageview", {
|
||
method: "POST",
|
||
headers: clientHeaders(),
|
||
});
|
||
} catch {
|
||
/* 埋点失败不影响浏览 */
|
||
}
|
||
}
|
||
|
||
export type AdminChatRoomType = "group" | "direct";
|
||
|
||
export interface AdminChatParticipant {
|
||
id: number;
|
||
name: string;
|
||
avatar: string;
|
||
}
|
||
|
||
export interface AdminChatMessage {
|
||
id: number;
|
||
room_id: number;
|
||
room_name: string;
|
||
room_type?: AdminChatRoomType;
|
||
is_default: boolean;
|
||
sender_id: number;
|
||
sender_name: string;
|
||
sender_avatar: string;
|
||
participants?: AdminChatParticipant[];
|
||
content: string;
|
||
recalled_at?: string | null;
|
||
created_at: string;
|
||
}
|
||
|
||
export async function apiAdminChatMessages(
|
||
page = 1,
|
||
q = "",
|
||
roomType: AdminChatRoomType = "group"
|
||
): Promise<{
|
||
messages: AdminChatMessage[];
|
||
total: number;
|
||
page: number;
|
||
}> {
|
||
const params = new URLSearchParams({
|
||
page: String(page),
|
||
room_type: roomType,
|
||
});
|
||
if (q.trim()) params.set("q", q.trim());
|
||
const res = await fetchWithRefresh(`/api/admin/chat/messages?${params}`, {
|
||
method: "GET",
|
||
headers: clientHeaders(),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "获取聊天消息失败");
|
||
return data;
|
||
}
|
||
|
||
export async function apiAdminRecallChatMessage(roomId: number, mid: number) {
|
||
const res = await fetchWithRefresh(`/api/admin/chat/rooms/${roomId}/messages/${mid}`, {
|
||
method: "DELETE",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiAdminEconomyStats(): Promise<EconomyStats & { error?: string }> {
|
||
const res = await fetchWithRefresh("/api/admin/economy", {
|
||
method: "GET",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function fetchAdminEconomyStats(
|
||
cookieHeader?: string
|
||
): Promise<EconomyStats> {
|
||
const res = await fetch(`${API_BASE}/api/admin/economy`, ssrInit(cookieHeaders(cookieHeader)));
|
||
if (!res.ok) throw new Error("failed");
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiUploadPostFile(
|
||
file: File,
|
||
pricePoints = 0
|
||
): Promise<{ attachment?: PostAttachment; error?: string }> {
|
||
const fd = new FormData();
|
||
fd.append("file", file);
|
||
fd.append("price_points", String(pricePoints));
|
||
const res = await fetchWithRefresh("/api/upload/file", {
|
||
method: "POST",
|
||
headers: clientHeaders(),
|
||
body: fd,
|
||
});
|
||
const ct = res.headers.get("content-type") || "";
|
||
if (!ct.includes("application/json")) {
|
||
if (res.status === 413) return { error: "附件过大" };
|
||
if (res.status >= 500) return { error: "上传失败,请稍后重试" };
|
||
return { error: "上传失败" };
|
||
}
|
||
const data = (await res.json().catch(() => ({}))) as {
|
||
attachment?: PostAttachment;
|
||
error?: string;
|
||
};
|
||
if (!res.ok) {
|
||
return { error: data.error || (res.status === 413 ? "附件过大" : "上传失败") };
|
||
}
|
||
return data;
|
||
}
|
||
|
||
export async function apiDeletePostFile(id: number) {
|
||
const res = await fetchWithRefresh(`/api/upload/file/${id}`, {
|
||
method: "DELETE",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiUpdatePostFilePrice(id: number, pricePoints: number) {
|
||
const res = await fetchWithRefresh(`/api/upload/file/${id}/price`, {
|
||
method: "PUT",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify({ price_points: pricePoints }),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export function postAttachmentDownloadURL(postId: number | string, attId: number) {
|
||
return `/api/posts/${postId}/attachments/${attId}/download`;
|
||
}
|
||
|
||
function filenameFromDisposition(header: string | null, fallback: string): string {
|
||
if (!header) return fallback;
|
||
const star = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(header);
|
||
if (star?.[1]) {
|
||
try {
|
||
return decodeURIComponent(star[1]);
|
||
} catch {
|
||
return fallback;
|
||
}
|
||
}
|
||
const quoted = /filename\s*=\s*"([^"]+)"/i.exec(header);
|
||
if (quoted?.[1]) return quoted[1];
|
||
const plain = /filename\s*=\s*([^;]+)/i.exec(header);
|
||
if (plain?.[1]) return plain[1].trim();
|
||
return fallback;
|
||
}
|
||
|
||
/** 下载帖子附件:走 fetch 以便处理 401/402 JSON,避免整页打开错误文档 */
|
||
export async function apiDownloadPostAttachment(
|
||
postId: number | string,
|
||
attId: number,
|
||
fallbackName: string
|
||
): Promise<{ blob?: Blob; filename?: string; error?: string; status?: number }> {
|
||
const res = await fetchWithRefresh(postAttachmentDownloadURL(postId, attId), {
|
||
method: "GET",
|
||
cache: "no-store",
|
||
headers: clientHeaders(),
|
||
});
|
||
if (!res.ok) {
|
||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||
return { error: data.error || "下载失败", status: res.status };
|
||
}
|
||
const blob = await res.blob();
|
||
return {
|
||
blob,
|
||
filename: filenameFromDisposition(res.headers.get("Content-Disposition"), fallbackName),
|
||
};
|
||
}
|
||
|
||
export async function apiVotePoll(postId: string, options: number[]) {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}/poll/vote`, {
|
||
method: "POST",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify({ options }),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiClosePoll(postId: string) {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}/poll/close`, {
|
||
method: "POST",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiAcceptBounty(postId: string, commentId: number) {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}/bounty/accept`, {
|
||
method: "POST",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify({ comment_id: commentId }),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiAcceptQuestion(postId: string, commentId: number) {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}/question/accept`, {
|
||
method: "POST",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify({ comment_id: commentId }),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiSolveQuestion(postId: string) {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}/question/solve`, {
|
||
method: "POST",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiReopenQuestion(postId: string) {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}/question/reopen`, {
|
||
method: "POST",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiDrawLottery(postId: string) {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}/lottery/draw`, {
|
||
method: "POST",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
/** 管理删帖类型(与后端 model.PostDeleteType* 一致) */
|
||
export const POST_DELETE_TYPES = [
|
||
{ value: "spam", label: "垃圾广告" },
|
||
{ value: "violation", label: "违规内容" },
|
||
{ value: "off_topic", label: "灌水跑题" },
|
||
{ value: "duplicate", label: "重复内容" },
|
||
{ value: "other", label: "其他" },
|
||
] as const;
|
||
|
||
export function postDeleteTypeLabel(t?: string): string {
|
||
return POST_DELETE_TYPES.find((x) => x.value === t)?.label || t || "";
|
||
}
|
||
|
||
export async function apiDeletePost(
|
||
postId: string,
|
||
opts?: { delete_type?: string; delete_reason?: string }
|
||
) {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}`, {
|
||
method: "DELETE",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify({
|
||
delete_type: opts?.delete_type || "",
|
||
delete_reason: opts?.delete_reason || "",
|
||
}),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiTogglePin(postId: string): Promise<{ pinned: number }> {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}/pin`, {
|
||
method: "PUT",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiToggleRecommend(postId: string): Promise<{ recommended: boolean }> {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}/recommend`, {
|
||
method: "PUT",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiChangePassword(oldPassword: string, newPassword: string) {
|
||
const res = await fetchWithRefresh("/api/change-password", {
|
||
method: "POST",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
/** 当前用户仍有效的登录会话(安全设置 SSR / 客户端) */
|
||
export interface LoginDevice {
|
||
id: number;
|
||
ip: string;
|
||
device_kind: "computer" | "mobile" | "tablet";
|
||
device_label: string;
|
||
browser: string;
|
||
current: boolean;
|
||
created_at: string;
|
||
}
|
||
|
||
export interface LoginDevicesResponse {
|
||
devices: LoginDevice[];
|
||
}
|
||
|
||
export async function fetchLoginDevices(
|
||
cookieHeader?: string,
|
||
viewer?: { userAgent?: string; clientIP?: string }
|
||
): Promise<LoginDevice[]> {
|
||
const headers: Record<string, string> = {};
|
||
if (cookieHeader) headers.Cookie = cookieHeader;
|
||
if (viewer?.userAgent) headers["X-J13-Client-UA"] = viewer.userAgent;
|
||
if (viewer?.clientIP) headers["X-J13-Client-IP"] = viewer.clientIP;
|
||
const res = await fetch(`${API_BASE}/api/me/login-devices`, ssrInit(headers));
|
||
if (res.status === 401) return [];
|
||
if (!res.ok) throw new Error("获取登录设备失败");
|
||
const data = (await res.json()) as LoginDevicesResponse;
|
||
return data.devices ?? [];
|
||
}
|
||
|
||
/** 剔除其它登录设备,该设备需重新输入密码登录 */
|
||
export async function apiRevokeLoginDevice(
|
||
id: number
|
||
): Promise<{ ok?: boolean; error?: string }> {
|
||
const res = await fetchWithRefresh(`/api/me/login-devices/${id}`, {
|
||
method: "DELETE",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
// 更新当前用户资料(昵称、头像、邮箱、签名)
|
||
export async function apiUpdateProfile(data: {
|
||
nickname: string;
|
||
email?: string;
|
||
signature?: string;
|
||
}): Promise<{ user?: User; error?: string }> {
|
||
const res = await fetchWithRefresh("/api/profile", {
|
||
method: "PUT",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify(data),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
// 媒体库附件(avatar = 头像,image = 帖子插图等)
|
||
export interface MediaAttachment {
|
||
id: number;
|
||
url: string;
|
||
kind: "avatar" | "image";
|
||
size: number;
|
||
width: number;
|
||
height: number;
|
||
created_at: string;
|
||
}
|
||
|
||
// 上传裁剪后的头像(Blob 必须是 WebP;不要手动设置 Content-Type,交给浏览器加 multipart 边界)
|
||
export async function apiUploadAvatar(
|
||
file: Blob
|
||
): Promise<{ url?: string; error?: string }> {
|
||
const form = new FormData();
|
||
form.append("file", file, "avatar.webp");
|
||
const res = await fetchWithRefresh("/api/upload/avatar", {
|
||
method: "POST",
|
||
headers: clientHeaders(),
|
||
body: form,
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
// 上传帖子插图(JPEG / PNG / WebP,≤5MB)
|
||
export async function apiUploadPostImage(
|
||
file: File | Blob,
|
||
filename = "image.jpg"
|
||
): Promise<{ url?: string; attachment?: MediaAttachment; error?: string }> {
|
||
const form = new FormData();
|
||
const name =
|
||
file instanceof File && file.name
|
||
? file.name
|
||
: filename;
|
||
form.append("file", file, name);
|
||
const res = await fetchWithRefresh("/api/upload/image", {
|
||
method: "POST",
|
||
headers: clientHeaders(),
|
||
body: form,
|
||
});
|
||
const ct = res.headers.get("content-type") || "";
|
||
if (!ct.includes("application/json")) {
|
||
if (res.status === 413) return { error: "图片过大" };
|
||
if (res.status >= 500) return { error: "上传失败,请稍后重试" };
|
||
return { error: "上传失败" };
|
||
}
|
||
const data = (await res.json().catch(() => ({}))) as {
|
||
url?: string;
|
||
attachment?: MediaAttachment;
|
||
error?: string;
|
||
};
|
||
if (!res.ok) {
|
||
return { error: data.error || (res.status === 413 ? "图片过大" : "上传失败") };
|
||
}
|
||
return data;
|
||
}
|
||
|
||
// 选用一张本人历史上传的头像
|
||
export async function apiUseAvatar(
|
||
url: string
|
||
): Promise<{ url?: string; error?: string }> {
|
||
const res = await fetchWithRefresh("/api/avatar/use", {
|
||
method: "PUT",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify({ url }),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
// 媒体库:本人上传的全部图片
|
||
export async function apiMyMedia(): Promise<{
|
||
attachments?: MediaAttachment[];
|
||
error?: string;
|
||
}> {
|
||
const res = await fetchWithRefresh("/api/my/media", {
|
||
method: "GET",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
// 彻底删除本人附件
|
||
export async function apiDeleteAttachment(
|
||
id: number
|
||
): Promise<{ ok?: boolean; error?: string }> {
|
||
const res = await fetchWithRefresh(`/api/my/attachments/${id}`, {
|
||
method: "DELETE",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiToggleLike(postId: string) {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}/like`, {
|
||
method: "POST",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiDeleteComment(
|
||
postId: string,
|
||
commentId: number,
|
||
opts: { delete_type?: string; delete_reason: string }
|
||
) {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}/comments/${commentId}`, {
|
||
method: "DELETE",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify({
|
||
delete_type: opts.delete_type || "",
|
||
delete_reason: opts.delete_reason || "",
|
||
}),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
/** 编辑评论(作者或版主) */
|
||
export async function apiUpdateComment(postId: string, commentId: number, content: string) {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}/comments/${commentId}`, {
|
||
method: "PUT",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify({ content }),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
/** 恢复软删评论(自删作者或版主) */
|
||
export async function apiRestoreComment(postId: string, commentId: number) {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}/comments/${commentId}/restore`, {
|
||
method: "PUT",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
/** 版主硬删评论及子树(不占位) */
|
||
export async function apiPurgeComment(postId: string, commentId: number) {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}/comments/${commentId}/purge`, {
|
||
method: "DELETE",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
// ===== 通知 =====
|
||
|
||
export async function apiFetchNotifications(page = 1): Promise<NotificationsResponse> {
|
||
const res = await fetchWithRefresh(`/api/notifications?page=${page}&size=20`, {
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiUnreadCount(): Promise<{ count: number }> {
|
||
const res = await fetchWithRefresh("/api/notifications/unread-count", {
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiMarkRead(id: number) {
|
||
const res = await fetchWithRefresh(`/api/notifications/${id}/read`, {
|
||
method: "PUT",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiMarkAllRead() {
|
||
const res = await fetchWithRefresh("/api/notifications/read-all", {
|
||
method: "PUT",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
// ===== 每日签到 =====
|
||
|
||
export async function apiGetCheckin(): Promise<CheckinStatus> {
|
||
const res = await fetchWithRefresh("/api/checkin", {
|
||
headers: clientHeaders(),
|
||
});
|
||
if (!res.ok) throw new Error("获取签到状态失败");
|
||
return res.json();
|
||
}
|
||
|
||
// 执行签到;失败(如今日已签,409)抛出后端消息
|
||
export async function apiDoCheckin(): Promise<CheckinStatus> {
|
||
const res = await fetchWithRefresh("/api/checkin", {
|
||
method: "POST",
|
||
headers: clientHeaders(),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "签到失败");
|
||
return data as CheckinStatus;
|
||
}
|
||
|
||
// ===== 站点公告 =====
|
||
|
||
// SSR 公告详情(仅已发布;转发 cookie 以便按登录态脱敏 hide)
|
||
export async function fetchAnnouncementDetail(
|
||
id: string,
|
||
cookieHeader?: string
|
||
): Promise<{ announcement: Announcement } | null> {
|
||
const res = await fetch(
|
||
`${API_BASE}/api/announcements/${id}`,
|
||
ssrInit(cookieHeaders(cookieHeader))
|
||
);
|
||
if (res.status === 404) return null;
|
||
if (!res.ok) throw new Error("获取公告失败");
|
||
return res.json();
|
||
}
|
||
|
||
// SSR 后台公告列表(含草稿)
|
||
export async function fetchAdminAnnouncements(
|
||
cookieHeader?: string
|
||
): Promise<{ announcements: Announcement[] }> {
|
||
const res = await fetch(
|
||
`${API_BASE}/api/admin/announcements`,
|
||
ssrInit(cookieHeaders(cookieHeader))
|
||
);
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "获取公告列表失败");
|
||
return data as { announcements: Announcement[] };
|
||
}
|
||
|
||
// 后台:公告列表(含草稿)
|
||
export async function apiAdminListAnnouncements(): Promise<{ announcements: Announcement[] }> {
|
||
const res = await fetchWithRefresh("/api/admin/announcements", {
|
||
headers: clientHeaders(),
|
||
});
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
throw new Error(data.error || "获取公告列表失败");
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
export interface AnnouncementInput {
|
||
title: string;
|
||
content: string;
|
||
tag: string;
|
||
tag_color: AnnouncementColor;
|
||
published: boolean;
|
||
pinned?: boolean;
|
||
}
|
||
|
||
async function saveAnnouncement(
|
||
method: "POST" | "PUT",
|
||
url: string,
|
||
input: AnnouncementInput
|
||
): Promise<{ announcement: Announcement }> {
|
||
const res = await fetchWithRefresh(url, {
|
||
method,
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify(input),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "保存公告失败");
|
||
return data as { announcement: Announcement };
|
||
}
|
||
|
||
export function apiAdminCreateAnnouncement(input: AnnouncementInput) {
|
||
return saveAnnouncement("POST", "/api/admin/announcements", input);
|
||
}
|
||
|
||
export function apiAdminUpdateAnnouncement(id: number, input: AnnouncementInput) {
|
||
return saveAnnouncement("PUT", `/api/admin/announcements/${id}`, input);
|
||
}
|
||
|
||
export async function apiAdminDeleteAnnouncement(id: number): Promise<void> {
|
||
const res = await fetchWithRefresh(`/api/admin/announcements/${id}`, {
|
||
method: "DELETE",
|
||
headers: clientHeaders(),
|
||
});
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
throw new Error(data.error || "删除公告失败");
|
||
}
|
||
}
|
||
|
||
export async function apiAdminToggleAnnouncementPin(
|
||
id: number
|
||
): Promise<{ announcement: Announcement }> {
|
||
const res = await fetchWithRefresh(`/api/admin/announcements/${id}/pin`, {
|
||
method: "POST",
|
||
headers: clientHeaders(),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "置顶失败");
|
||
return data as { announcement: Announcement };
|
||
}
|
||
|
||
/** SSR / 公开:分页公告列表 */
|
||
export async function fetchAnnouncementsPage(
|
||
page = 1,
|
||
size = 20
|
||
): Promise<{ announcements: Announcement[]; total: number; page: number }> {
|
||
const res = await fetch(
|
||
`${API_BASE}/api/announcements?page=${page}&size=${size}`,
|
||
ssrInit()
|
||
);
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "获取公告失败");
|
||
return data as { announcements: Announcement[]; total: number; page: number };
|
||
}
|
||
|
||
export type TimelineGitItem = {
|
||
date: string;
|
||
title: string;
|
||
body: string;
|
||
source_url?: string;
|
||
sha?: string;
|
||
};
|
||
|
||
export async function apiTimelineFromGit(input: {
|
||
urls: string[];
|
||
follow_pages?: boolean;
|
||
}): Promise<{ items: TimelineGitItem[]; warning?: string; error?: string }> {
|
||
const res = await fetchWithRefresh("/api/timeline/from-git", {
|
||
method: "POST",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify(input),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "导入失败");
|
||
return data as { items: TimelineGitItem[]; warning?: string; error?: string };
|
||
}
|
||
|
||
/** SSR / 公开:已发布单页目录 */
|
||
export async function fetchPublishedSitePages(): Promise<{ pages: SitePageSummary[] }> {
|
||
const res = await fetch(`${API_BASE}/api/pages`, ssrInit());
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "获取页面失败");
|
||
return { pages: data.pages ?? [] };
|
||
}
|
||
|
||
export async function fetchSitePageBySlug(
|
||
slug: string
|
||
): Promise<{ page: SitePage } | null> {
|
||
const res = await fetch(`${API_BASE}/api/pages/${encodeURIComponent(slug)}`, ssrInit());
|
||
if (res.status === 404) return null;
|
||
if (!res.ok) throw new Error("获取页面失败");
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiAdminListSitePages(): Promise<{ pages: SitePage[] }> {
|
||
const res = await fetchWithRefresh("/api/admin/pages", { headers: clientHeaders() });
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "获取单页失败");
|
||
return data as { pages: SitePage[] };
|
||
}
|
||
|
||
export async function fetchAdminSitePages(
|
||
cookieHeader?: string
|
||
): Promise<{ pages: SitePage[] }> {
|
||
const res = await fetch(`${API_BASE}/api/admin/pages`, ssrInit(cookieHeaders(cookieHeader)));
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "获取单页失败");
|
||
return data as { pages: SitePage[] };
|
||
}
|
||
|
||
async function saveSitePage(
|
||
method: "POST" | "PUT",
|
||
url: string,
|
||
input: SitePageInput
|
||
): Promise<{ page: SitePage }> {
|
||
const res = await fetchWithRefresh(url, {
|
||
method,
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify(input),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "保存页面失败");
|
||
return data as { page: SitePage };
|
||
}
|
||
|
||
export function apiAdminCreateSitePage(input: SitePageInput) {
|
||
return saveSitePage("POST", "/api/admin/pages", input);
|
||
}
|
||
|
||
export function apiAdminUpdateSitePage(id: number, input: SitePageInput) {
|
||
return saveSitePage("PUT", `/api/admin/pages/${id}`, input);
|
||
}
|
||
|
||
export async function apiAdminDeleteSitePage(id: number): Promise<void> {
|
||
const res = await fetchWithRefresh(`/api/admin/pages/${id}`, {
|
||
method: "DELETE",
|
||
headers: clientHeaders(),
|
||
});
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
throw new Error(data.error || "删除页面失败");
|
||
}
|
||
}
|
||
|
||
export async function apiGetAdminSettings(): Promise<
|
||
PublicSettings & { timeline_git_import?: string }
|
||
> {
|
||
const res = await fetchWithRefresh("/api/admin/settings", { headers: clientHeaders() });
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "获取设置失败");
|
||
return {
|
||
...normalizePublicSettings(data as Partial<PublicSettings>),
|
||
timeline_git_import:
|
||
typeof data.timeline_git_import === "string" ? data.timeline_git_import : undefined,
|
||
};
|
||
}
|
||
|
||
// ===== 用户管理(管理员) =====
|
||
|
||
// 后台用户列表项: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;
|
||
points?: number;
|
||
post_count: number;
|
||
comment_count: number;
|
||
created_at: string;
|
||
last_seen_at: string | null;
|
||
online: boolean; // last_seen_at 在 5 分钟内
|
||
last_login_ip: string;
|
||
last_login_at: string | null;
|
||
}
|
||
|
||
// 登录历史记录
|
||
export interface LoginLog {
|
||
id: number;
|
||
user_id: number;
|
||
username: string;
|
||
ip: string;
|
||
user_agent: string;
|
||
success: boolean;
|
||
created_at: string;
|
||
}
|
||
|
||
export interface LoginLogsResponse {
|
||
logs: LoginLog[];
|
||
total: number;
|
||
page: number;
|
||
}
|
||
|
||
export interface AdminUserSummary {
|
||
online: number;
|
||
in_cooldown: number;
|
||
active_7d: number;
|
||
failed_logins_24h: number;
|
||
total: number;
|
||
admins: number;
|
||
banned: number;
|
||
}
|
||
|
||
export interface AdminUsersResponse {
|
||
users: AdminUser[];
|
||
total: number;
|
||
page: number;
|
||
size: number;
|
||
summary: AdminUserSummary;
|
||
}
|
||
|
||
export interface AdminUsersQuery {
|
||
page?: number;
|
||
size?: number;
|
||
q?: string;
|
||
role?: string;
|
||
status?: string;
|
||
}
|
||
|
||
function adminUsersParams(query: AdminUsersQuery): URLSearchParams {
|
||
const params = new URLSearchParams();
|
||
if (query.page && query.page > 1) params.set("page", String(query.page));
|
||
if (query.size) params.set("size", String(query.size));
|
||
if (query.q) params.set("q", query.q);
|
||
if (query.role) params.set("role", query.role);
|
||
if (query.status) params.set("status", query.status);
|
||
return params;
|
||
}
|
||
|
||
// SSR 后台用户列表(转发管理员 cookie 直连后端);权限不足时抛错由页面层处理
|
||
export async function fetchAdminUsers(
|
||
query: AdminUsersQuery,
|
||
cookieHeader?: string
|
||
): Promise<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 };
|
||
}
|
||
|
||
// 某用户的登录历史(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 interface AdminUserAuditProfile {
|
||
user: AdminUser;
|
||
posts_total: number;
|
||
comments_total: number;
|
||
messages_total: number;
|
||
published_posts: number;
|
||
published_comments: number;
|
||
}
|
||
|
||
export interface AdminAuditPostItem {
|
||
id: number;
|
||
title: string;
|
||
content: string;
|
||
status: string;
|
||
deleted: boolean;
|
||
board_id: number;
|
||
board_name: string;
|
||
created_at: string;
|
||
deleted_at?: string | null;
|
||
}
|
||
|
||
export interface AdminAuditCommentItem {
|
||
id: number;
|
||
post_id: number;
|
||
content: string;
|
||
status: string;
|
||
deleted: boolean;
|
||
created_at: string;
|
||
deleted_at?: string | null;
|
||
}
|
||
|
||
export interface AdminAuditMessageItem {
|
||
id: number;
|
||
room_id: number;
|
||
room_name: string;
|
||
room_type: string;
|
||
content: string;
|
||
recalled: boolean;
|
||
deleted: boolean;
|
||
created_at: string;
|
||
deleted_at?: string | null;
|
||
}
|
||
|
||
export interface AdminAuditListResponse<T> {
|
||
items: T[];
|
||
total: number;
|
||
page: number;
|
||
}
|
||
|
||
export async function fetchAdminUserAudit(
|
||
id: number,
|
||
cookieHeader?: string
|
||
): Promise<AdminUserAuditProfile> {
|
||
const res = await fetch(
|
||
`${API_BASE}/api/admin/users/${id}`,
|
||
ssrInit(cookieHeaders(cookieHeader))
|
||
);
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "获取用户档案失败");
|
||
return data as AdminUserAuditProfile;
|
||
}
|
||
|
||
export async function fetchAdminUserAuditPosts(
|
||
id: number,
|
||
page = 1,
|
||
cookieHeader?: string
|
||
): Promise<AdminAuditListResponse<AdminAuditPostItem>> {
|
||
const res = await fetch(
|
||
`${API_BASE}/api/admin/users/${id}/posts?page=${page}`,
|
||
ssrInit(cookieHeaders(cookieHeader))
|
||
);
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "获取帖子失败");
|
||
return data as AdminAuditListResponse<AdminAuditPostItem>;
|
||
}
|
||
|
||
export async function apiAdminUserAudit(id: number): Promise<AdminUserAuditProfile> {
|
||
const res = await fetchWithRefresh(`/api/admin/users/${id}`, {
|
||
headers: clientHeaders(),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "获取用户档案失败");
|
||
return data as AdminUserAuditProfile;
|
||
}
|
||
|
||
export async function apiAdminUserAuditPosts(
|
||
id: number,
|
||
page = 1
|
||
): Promise<AdminAuditListResponse<AdminAuditPostItem>> {
|
||
const res = await fetchWithRefresh(`/api/admin/users/${id}/posts?page=${page}`, {
|
||
headers: clientHeaders(),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "获取帖子失败");
|
||
return data as AdminAuditListResponse<AdminAuditPostItem>;
|
||
}
|
||
|
||
export async function apiAdminUserAuditComments(
|
||
id: number,
|
||
page = 1
|
||
): Promise<AdminAuditListResponse<AdminAuditCommentItem>> {
|
||
const res = await fetchWithRefresh(`/api/admin/users/${id}/comments?page=${page}`, {
|
||
headers: clientHeaders(),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "获取评论失败");
|
||
return data as AdminAuditListResponse<AdminAuditCommentItem>;
|
||
}
|
||
|
||
export async function apiAdminUserAuditMessages(
|
||
id: number,
|
||
page = 1
|
||
): Promise<AdminAuditListResponse<AdminAuditMessageItem>> {
|
||
const res = await fetchWithRefresh(`/api/admin/users/${id}/messages?page=${page}`, {
|
||
headers: clientHeaders(),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "获取消息失败");
|
||
return data as AdminAuditListResponse<AdminAuditMessageItem>;
|
||
}
|
||
|
||
// ===== 站点外观设置 =====
|
||
|
||
export async function apiGetSettings(): Promise<PublicSettings> {
|
||
// no-store:主题同步必须读到管理员刚保存的最新配色,不走浏览器缓存
|
||
const res = await fetchWithRefresh("/api/settings", { method: "GET", cache: "no-store" });
|
||
if (!res.ok) throw new Error("获取站点设置失败");
|
||
return normalizePublicSettings(await res.json());
|
||
}
|
||
|
||
export type UpdateSiteSettingsBody = {
|
||
accent?: string;
|
||
trust_reviewed_publish?: boolean;
|
||
site_name?: string;
|
||
site_description?: string;
|
||
allow_register?: boolean;
|
||
allow_comments?: boolean;
|
||
allow_messages?: boolean;
|
||
post_cooldown_hours?: number;
|
||
code_block_auto_fold?: boolean;
|
||
code_block_fold_lines?: number;
|
||
ui_animations?: boolean;
|
||
anim_code_fold?: boolean;
|
||
anim_smooth_scroll?: boolean;
|
||
anim_chrome?: boolean;
|
||
post_link_new_tab?: boolean;
|
||
attachment_ext_limit?: boolean;
|
||
attachment_exts?: string[];
|
||
attachment_max_mb?: number;
|
||
attachment_max_count?: number;
|
||
image_max_mb?: number;
|
||
custom_css?: string;
|
||
custom_js?: string;
|
||
timeline_git_import?: 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>);
|
||
}
|
||
|
||
// ===== 板块(客户端,供角色授权弹窗勾选) =====
|
||
|
||
export async function apiListBoards(): Promise<{ boards: Board[] }> {
|
||
const res = await fetchWithRefresh("/api/boards", { headers: clientHeaders() });
|
||
if (!res.ok) throw new Error("获取板块失败");
|
||
return res.json();
|
||
}
|
||
|
||
// ===== 内容审核动作 =====
|
||
|
||
export async function apiAdminPendingCounts(): Promise<PendingCounts> {
|
||
const res = await fetchWithRefresh("/api/admin/moderation/counts", {
|
||
headers: clientHeaders(),
|
||
});
|
||
if (!res.ok) return { posts: 0, comments: 0 };
|
||
return res.json();
|
||
}
|
||
|
||
async function moderationAction(url: string): Promise<void> {
|
||
const res = await fetchWithRefresh(url, { method: "PUT", headers: clientHeaders() });
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
throw new Error(data.error || "操作失败");
|
||
}
|
||
}
|
||
|
||
export const apiAdminApprovePost = (id: number) =>
|
||
moderationAction(`/api/admin/moderation/posts/${id}/approve`);
|
||
export const apiAdminRejectPost = (id: number) =>
|
||
moderationAction(`/api/admin/moderation/posts/${id}/reject`);
|
||
export const apiAdminApproveComment = (id: number) =>
|
||
moderationAction(`/api/admin/moderation/comments/${id}/approve`);
|
||
export const apiAdminRejectComment = (id: number) =>
|
||
moderationAction(`/api/admin/moderation/comments/${id}/reject`);
|
||
|
||
// ===== 内容库(帖子 / 评论 + 回收站) =====
|
||
|
||
export type AdminContentStatus = "live" | "pending" | "rejected" | "published" | "deleted";
|
||
|
||
export interface AdminContentCounts {
|
||
posts_live: number;
|
||
posts_pending: number;
|
||
posts_rejected: number;
|
||
posts_deleted: number;
|
||
comments_live: number;
|
||
comments_pending: number;
|
||
comments_rejected: number;
|
||
comments_deleted: number;
|
||
}
|
||
|
||
export interface AdminContentPost {
|
||
id: number;
|
||
title: string;
|
||
status: string;
|
||
deleted: boolean;
|
||
deleted_at?: string;
|
||
created_at: string;
|
||
comment_count: number;
|
||
board_id: number;
|
||
board: Board;
|
||
user: User;
|
||
}
|
||
|
||
export interface AdminContentComment {
|
||
id: number;
|
||
post_id: number;
|
||
post_title: string;
|
||
board_id: number;
|
||
content: string;
|
||
status: string;
|
||
deleted: boolean;
|
||
deleted_at?: string;
|
||
created_at: string;
|
||
board: Board;
|
||
user: User;
|
||
}
|
||
|
||
export async function apiAdminContentCounts(): Promise<AdminContentCounts> {
|
||
const res = await fetchWithRefresh("/api/admin/content/counts", { headers: clientHeaders() });
|
||
if (!res.ok) throw new Error("获取内容统计失败");
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiAdminContentPosts(
|
||
page = 1,
|
||
status: AdminContentStatus = "live",
|
||
q = ""
|
||
): Promise<{ posts: AdminContentPost[]; total: number; page: number }> {
|
||
const params = new URLSearchParams({ page: String(page), status });
|
||
if (q.trim()) params.set("q", q.trim());
|
||
const res = await fetchWithRefresh(`/api/admin/content/posts?${params}`, {
|
||
headers: clientHeaders(),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "获取帖子列表失败");
|
||
return data;
|
||
}
|
||
|
||
export async function apiAdminContentComments(
|
||
page = 1,
|
||
status: AdminContentStatus = "live",
|
||
q = ""
|
||
): Promise<{ comments: AdminContentComment[]; total: number; page: number }> {
|
||
const params = new URLSearchParams({ page: String(page), status });
|
||
if (q.trim()) params.set("q", q.trim());
|
||
const res = await fetchWithRefresh(`/api/admin/content/comments?${params}`, {
|
||
headers: clientHeaders(),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "获取评论列表失败");
|
||
return data;
|
||
}
|
||
|
||
async function contentAction(
|
||
url: string,
|
||
method: string,
|
||
body?: Record<string, unknown>
|
||
): Promise<void> {
|
||
const res = await fetchWithRefresh(url, {
|
||
method,
|
||
headers: clientHeaders(body ? { "Content-Type": "application/json" } : undefined),
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
});
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
throw new Error(data.error || "操作失败");
|
||
}
|
||
}
|
||
|
||
export const apiAdminSoftDeletePost = (
|
||
id: number,
|
||
opts: { delete_type: string; delete_reason: string }
|
||
) => contentAction(`/api/admin/content/posts/${id}`, "DELETE", opts);
|
||
export const apiAdminRestorePost = (id: number) =>
|
||
contentAction(`/api/admin/content/posts/${id}/restore`, "PUT");
|
||
export const apiAdminPurgePost = (id: number) =>
|
||
contentAction(`/api/admin/content/posts/${id}/purge`, "DELETE");
|
||
export const apiAdminSoftDeleteComment = (
|
||
id: number,
|
||
opts: { delete_type: string; delete_reason: string }
|
||
) => contentAction(`/api/admin/content/comments/${id}`, "DELETE", opts);
|
||
export const apiAdminRestoreComment = (id: number) =>
|
||
contentAction(`/api/admin/content/comments/${id}/restore`, "PUT");
|
||
export const apiAdminPurgeComment = (id: number) =>
|
||
contentAction(`/api/admin/content/comments/${id}/purge`, "DELETE");
|
||
|
||
export interface CommentEditHistoryItem {
|
||
id: number;
|
||
editor: {
|
||
id: number;
|
||
username: string;
|
||
nickname: string;
|
||
avatar: string;
|
||
};
|
||
old_content: string;
|
||
created_at: string;
|
||
}
|
||
|
||
/** 管理:评论编辑历史(分页;默认每页 10 条) */
|
||
export async function apiAdminCommentHistory(
|
||
commentId: number,
|
||
page = 1,
|
||
size = 10
|
||
): Promise<{ items: CommentEditHistoryItem[]; total: number; page: number; size: number }> {
|
||
const params = new URLSearchParams({ page: String(page), size: String(size) });
|
||
const res = await fetchWithRefresh(
|
||
`/api/admin/content/comments/${commentId}/history?${params}`,
|
||
{ headers: clientHeaders() }
|
||
);
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "获取编辑历史失败");
|
||
return data;
|
||
}
|
||
|
||
export async function fetchAdminContentCounts(
|
||
cookieHeader?: string
|
||
): Promise<AdminContentCounts> {
|
||
const res = await fetch(`${API_BASE}/api/admin/content/counts`, ssrInit(cookieHeaders(cookieHeader)));
|
||
if (!res.ok) {
|
||
return {
|
||
posts_live: 0,
|
||
posts_pending: 0,
|
||
posts_rejected: 0,
|
||
posts_deleted: 0,
|
||
comments_live: 0,
|
||
comments_pending: 0,
|
||
comments_rejected: 0,
|
||
comments_deleted: 0,
|
||
};
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
export async function fetchAdminContentPosts(
|
||
page = 1,
|
||
status: AdminContentStatus = "live",
|
||
cookieHeader?: string
|
||
): Promise<{ posts: AdminContentPost[]; total: number; page: number }> {
|
||
const res = await fetch(
|
||
`${API_BASE}/api/admin/content/posts?page=${page}&status=${status}`,
|
||
ssrInit(cookieHeaders(cookieHeader))
|
||
);
|
||
if (!res.ok) throw new Error("获取帖子列表失败");
|
||
return res.json();
|
||
}
|
||
|
||
export async function fetchAdminContentComments(
|
||
page = 1,
|
||
status: AdminContentStatus = "live",
|
||
cookieHeader?: string
|
||
): Promise<{ comments: AdminContentComment[]; total: number; page: number }> {
|
||
const res = await fetch(
|
||
`${API_BASE}/api/admin/content/comments?page=${page}&status=${status}`,
|
||
ssrInit(cookieHeaders(cookieHeader))
|
||
);
|
||
if (!res.ok) throw new Error("获取评论列表失败");
|
||
return res.json();
|
||
}
|
||
|
||
// ===== 群聊(二期) =====
|
||
|
||
// 群聊房间基础模型(与后端 model.ChatRoom 对齐)
|
||
export interface ChatRoom {
|
||
id: number;
|
||
name: string;
|
||
description: string;
|
||
owner_id: number;
|
||
room_type: "group" | "direct";
|
||
member_count: number;
|
||
last_message_id: number;
|
||
is_private: boolean;
|
||
is_default: boolean;
|
||
created_at: string;
|
||
updated_at: string;
|
||
owner: User;
|
||
last_message?: ChatMessage | null;
|
||
}
|
||
|
||
// 列表/详情聚合视图:扁平房间 + 当前访问者相关字段
|
||
export interface ChatRoomView extends ChatRoom {
|
||
joined: boolean;
|
||
my_role: "" | "owner" | "member" | "overseer";
|
||
unread_count: number;
|
||
pinned?: boolean;
|
||
pin_forced?: boolean;
|
||
peer?: User | null;
|
||
}
|
||
|
||
// 群成员行(名单/踢人用)
|
||
export interface ChatRoomMember {
|
||
id: number;
|
||
room_id: number;
|
||
user_id: number;
|
||
role: "owner" | "member";
|
||
last_read_message_id: number;
|
||
muted: boolean;
|
||
created_at: string;
|
||
updated_at: string;
|
||
user: User;
|
||
}
|
||
|
||
// 群消息(发送者已 Preload)
|
||
export interface ChatMessage {
|
||
id: number;
|
||
room_id: number;
|
||
sender_id: number;
|
||
content: string;
|
||
reply_to_id?: number;
|
||
reply_snap?: string;
|
||
created_at: string;
|
||
recalled_at?: string | null;
|
||
recalled_by?: number;
|
||
sender: User;
|
||
/** 撤回操作者(已撤回时后端 Preload;本人撤回时通常等于 sender) */
|
||
recaller?: User | null;
|
||
}
|
||
|
||
// 单群未读明细
|
||
export interface UnreadRoom {
|
||
room_id: number;
|
||
count: number;
|
||
}
|
||
|
||
export interface ChatRoomsResponse {
|
||
rooms: ChatRoomView[];
|
||
total: number;
|
||
page: number;
|
||
size: number;
|
||
}
|
||
|
||
export interface ChatMembersResponse {
|
||
members: ChatRoomMember[];
|
||
}
|
||
|
||
export interface ChatMessagesResponse {
|
||
messages: ChatMessage[];
|
||
has_more: boolean;
|
||
}
|
||
|
||
export interface ChatUnreadSummaryResponse {
|
||
total: number;
|
||
rooms: UnreadRoom[];
|
||
}
|
||
|
||
// SSR 消息会话列表(默认仅已加入)
|
||
export async function fetchChatRooms(
|
||
page = 1,
|
||
size = 40,
|
||
q = "",
|
||
cookieHeader?: string,
|
||
scope: "joined" | "all" = "joined"
|
||
): Promise<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 apiSearchChatUsers(
|
||
q: string
|
||
): Promise<{ users: User[] }> {
|
||
const params = new URLSearchParams({ q });
|
||
const res = await fetchWithRefresh(`/api/chat/users?${params}`, {
|
||
headers: clientHeaders(),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "搜索失败");
|
||
return data as { users: User[] };
|
||
}
|
||
|
||
// 客户端:建群(可公开/私密,私密可带初始成员用户名)
|
||
export async function apiCreateChatRoom(
|
||
name: string,
|
||
description = "",
|
||
opts?: { is_private?: boolean; member_usernames?: string[] }
|
||
): Promise<{ room: ChatRoomView }> {
|
||
const res = await fetchWithRefresh("/api/chat/rooms", {
|
||
method: "POST",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify({
|
||
name,
|
||
description,
|
||
is_private: opts?.is_private ?? false,
|
||
member_usernames: opts?.member_usernames ?? [],
|
||
}),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "建群失败");
|
||
return data as { room: ChatRoomView };
|
||
}
|
||
|
||
// 客户端:群详情
|
||
export async function apiGetChatRoom(id: number): Promise<{ room: ChatRoomView }> {
|
||
const res = await fetchWithRefresh(`/api/chat/rooms/${id}`, {
|
||
headers: clientHeaders(),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "获取群详情失败");
|
||
return data as { room: ChatRoomView };
|
||
}
|
||
|
||
// 客户端:群主改群名/简介
|
||
export async function apiUpdateChatRoom(
|
||
id: number,
|
||
name: string,
|
||
description = ""
|
||
): Promise<{ room: ChatRoomView }> {
|
||
const res = await fetchWithRefresh(`/api/chat/rooms/${id}`, {
|
||
method: "PUT",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify({ name, description }),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "修改群信息失败");
|
||
return data as { room: ChatRoomView };
|
||
}
|
||
|
||
// 客户端:置顶/取消置顶(大厅强制置顶不可取消)
|
||
export async function apiPinChatRoom(
|
||
id: number,
|
||
pinned: boolean
|
||
): Promise<{ room: ChatRoomView }> {
|
||
const res = await fetchWithRefresh(`/api/chat/rooms/${id}/pin`, {
|
||
method: "PUT",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify({ pinned }),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "置顶失败");
|
||
return data as { room: ChatRoomView };
|
||
}
|
||
|
||
// 客户端:群主解散群
|
||
export async function apiDissolveChatRoom(id: number): Promise<{ ok: boolean }> {
|
||
const res = await fetchWithRefresh(`/api/chat/rooms/${id}`, {
|
||
method: "DELETE",
|
||
headers: clientHeaders(),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "解散群失败");
|
||
return data as { ok: boolean };
|
||
}
|
||
|
||
// 客户端:加入群聊
|
||
export async function apiJoinChatRoom(
|
||
id: number
|
||
): Promise<{ ok: boolean; joined: boolean }> {
|
||
const res = await fetchWithRefresh(`/api/chat/rooms/${id}/join`, {
|
||
method: "POST",
|
||
headers: clientHeaders(),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "加入群失败");
|
||
return data as { ok: boolean; joined: boolean };
|
||
}
|
||
|
||
// 客户端:退出群聊
|
||
export async function apiLeaveChatRoom(id: number): Promise<{ ok: boolean }> {
|
||
const res = await fetchWithRefresh(`/api/chat/rooms/${id}/leave`, {
|
||
method: "POST",
|
||
headers: clientHeaders(),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "退出群失败");
|
||
return data as { ok: boolean };
|
||
}
|
||
|
||
// 客户端:成员名单
|
||
export async function apiListChatMembers(
|
||
id: number
|
||
): Promise<ChatMembersResponse> {
|
||
const res = await fetchWithRefresh(`/api/chat/rooms/${id}/members`, {
|
||
headers: clientHeaders(),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "获取成员名单失败");
|
||
return data as ChatMembersResponse;
|
||
}
|
||
|
||
// 客户端:群主踢人
|
||
export async function apiKickChatMember(
|
||
roomId: number,
|
||
userId: number
|
||
): Promise<{ ok: boolean }> {
|
||
const res = await fetchWithRefresh(
|
||
`/api/chat/rooms/${roomId}/members/${userId}`,
|
||
{ method: "DELETE", headers: clientHeaders() }
|
||
);
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "踢人失败");
|
||
return data as { ok: boolean };
|
||
}
|
||
|
||
// 客户端:邀请成员(用户名列表,群主/监管)
|
||
export async function 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();
|
||
}
|