1. 新增ThemeSync组件实现站点主题色无刷新同步 2. 给获取站点设置的接口添加no-store缓存策略确保拿到最新配置 3. 调整主题应用代码的属性设置顺序避免逻辑问题
1046 lines
31 KiB
TypeScript
1046 lines
31 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;
|
||
// 后端 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";
|
||
|
||
// 右栏站点公告摘要(标签/日期/标题)
|
||
export interface AnnouncementSummary {
|
||
id: number;
|
||
title: string;
|
||
tag: string;
|
||
tag_color: AnnouncementColor;
|
||
created_at: string;
|
||
}
|
||
|
||
// 公告详情/后台管理完整对象
|
||
export interface Announcement {
|
||
id: number;
|
||
title: string;
|
||
content: string;
|
||
tag: string;
|
||
tag_color: AnnouncementColor;
|
||
published: boolean;
|
||
created_at: string;
|
||
updated_at: string;
|
||
}
|
||
|
||
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[];
|
||
new_users: NewUser[];
|
||
checkin?: CheckinStatus | null;
|
||
}
|
||
|
||
export interface PostListItem {
|
||
id: number;
|
||
board_id: number;
|
||
user_id: number;
|
||
title: string;
|
||
tags: string;
|
||
post_type: string;
|
||
pinned: number;
|
||
recommended: boolean;
|
||
like_count: number;
|
||
view_count: number;
|
||
comment_count: number;
|
||
liked: boolean;
|
||
created_at: string;
|
||
last_reply?: {
|
||
user: Pick<User, "id" | "username" | "nickname" | "avatar">;
|
||
created_at: string;
|
||
} | null;
|
||
board: Board;
|
||
user: User;
|
||
}
|
||
|
||
export interface Post {
|
||
id: number;
|
||
board_id: number;
|
||
user_id: number;
|
||
title: string;
|
||
content: string;
|
||
tags: string;
|
||
post_type: 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;
|
||
}
|
||
|
||
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;
|
||
user: User;
|
||
}
|
||
|
||
// 评论树节点:主评论为根,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;
|
||
};
|
||
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";
|
||
post_id: number;
|
||
comment_id: number;
|
||
content: string;
|
||
is_read: boolean;
|
||
created_at: string;
|
||
actor: User;
|
||
post: { id: number; title: string };
|
||
}
|
||
|
||
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",
|
||
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();
|
||
}
|
||
|
||
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(空串=内置默认)
|
||
export interface PublicSettings {
|
||
accent: string;
|
||
}
|
||
|
||
// SSR 读取公开站点设置(主题色注入用);后端不可用时降级为默认色,不阻塞渲染
|
||
export async function fetchPublicSettings(): Promise<PublicSettings> {
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/settings`, ssrInit());
|
||
if (!res.ok) return { accent: "" };
|
||
return (await res.json()) as PublicSettings;
|
||
} catch {
|
||
return { accent: "" };
|
||
}
|
||
}
|
||
|
||
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("获取评论失败");
|
||
return res.json();
|
||
}
|
||
|
||
// 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;
|
||
}) {
|
||
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 }
|
||
) {
|
||
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 apiDeletePost(postId: string) {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}`, {
|
||
method: "DELETE",
|
||
headers: clientHeaders(),
|
||
});
|
||
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();
|
||
}
|
||
|
||
// 更新当前用户资料(昵称、头像、邮箱、签名)
|
||
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();
|
||
}
|
||
|
||
// 选用一张本人历史上传的头像
|
||
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) {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}/comments/${commentId}`, {
|
||
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 公告详情(仅已发布;草稿/不存在由调用方按 404 处理)
|
||
export async function fetchAnnouncementDetail(
|
||
id: string
|
||
): Promise<{ announcement: Announcement } | null> {
|
||
const res = await fetch(`${API_BASE}/api/announcements/${id}`, ssrInit());
|
||
if (res.status === 404) return null;
|
||
if (!res.ok) throw new Error("获取公告失败");
|
||
return res.json();
|
||
}
|
||
|
||
// 后台:公告列表(含草稿)
|
||
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;
|
||
}
|
||
|
||
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 || "删除公告失败");
|
||
}
|
||
}
|
||
|
||
// ===== 用户管理(管理员) =====
|
||
|
||
// 后台用户列表项: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;
|
||
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 {
|
||
total: number;
|
||
admins: number;
|
||
banned: number;
|
||
today_new: 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 待审核帖子首页(转发管理团队 cookie 直连后端);权限不足时抛错由页面层处理
|
||
export async function fetchAdminPendingPosts(
|
||
page = 1,
|
||
cookieHeader?: string
|
||
): Promise<{ posts: Post[]; total: number; page: number }> {
|
||
const res = await fetch(
|
||
`${API_BASE}/api/admin/moderation/pending-posts?page=${page}`,
|
||
ssrInit(cookieHeaders(cookieHeader))
|
||
);
|
||
if (!res.ok) throw new Error("获取待审核帖子失败");
|
||
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 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 res.json();
|
||
}
|
||
|
||
// 更新站点主题色;accent 传空串恢复默认
|
||
export async function apiUpdateSiteSettings(accent: string): Promise<PublicSettings> {
|
||
const res = await fetchWithRefresh("/api/admin/settings", {
|
||
method: "PUT",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify({ accent }),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "保存设置失败");
|
||
return { accent: data.accent ?? "" };
|
||
}
|
||
|
||
// ===== 板块(客户端,供角色授权弹窗勾选) =====
|
||
|
||
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 interface PendingCommentItem {
|
||
id: number;
|
||
post_id: number;
|
||
post_title: string;
|
||
board_id: number;
|
||
board: Board;
|
||
content: string;
|
||
created_at: string;
|
||
user: User;
|
||
}
|
||
|
||
export interface PendingCounts {
|
||
posts: number;
|
||
comments: number;
|
||
}
|
||
|
||
export async function apiAdminPendingPosts(page = 1): Promise<{
|
||
posts: Post[];
|
||
total: number;
|
||
page: number;
|
||
}> {
|
||
const res = await fetchWithRefresh(`/api/admin/moderation/pending-posts?page=${page}`, {
|
||
headers: clientHeaders(),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "获取待审帖子失败");
|
||
return data;
|
||
}
|
||
|
||
export async function apiAdminPendingComments(page = 1): Promise<{
|
||
comments: PendingCommentItem[];
|
||
total: number;
|
||
page: number;
|
||
}> {
|
||
const res = await fetchWithRefresh(`/api/admin/moderation/pending-comments?page=${page}`, {
|
||
headers: clientHeaders(),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "获取待审评论失败");
|
||
return data;
|
||
}
|
||
|
||
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`);
|