Files
jiang13-bbs/frontend/lib/api.ts
freefire 24c6dea38c feat: 完成头像上传、站点主题配置与UI优化
- 新增附件模型与上传服务,拆分头像上传/管理逻辑
- 重构用户资料接口,移除直接修改头像字段,改用专用上传接口
- 添加站点公开设置API与管理员外观配置页
- 全局替换hover背景为accent-soft,优化交互一致性
- 完善头像组件,支持图片加载失败回退与src更新重置
- 新增用户状态事件总线,解决同页头像更新不及时问题
- 调整依赖包顺序与CI依赖版本
- 重构板块列表接口,新增帖子计数
- 迁移账号设置到用户主页tab,合并重复代码
2026-09-15 01:06:12 +08:00

761 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { CSRF_COOKIE } from "./cookies";
// 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 与 /api/profile 响应中返回(用户自身敏感信息)
email?: string;
signature?: string;
}
// /api/me 响应:用户信息 + 未读通知数(SSR layout 一次请求直出)
export interface MeResponse {
user: User | null;
unread_count: number;
}
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;
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";
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<boolean> | null = null;
// 调用 /api/auth/refresh 刷新 access token(refresh cookie 由浏览器自动携带)
async function apiRefresh(): Promise<boolean> {
try {
const res = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include",
headers: clientHeaders(),
});
return res.ok;
} catch {
return false;
}
}
// 带自动续期的 fetch:遇到 401 时尝试 refresh,成功后重试原请求
async function fetchWithRefresh(url: string, init: RequestInit): Promise<Response> {
let res = await fetch(url, { ...init, credentials: "include" });
if (res.status === 401) {
// 串行化 refresh:多个并发 401 只触发一次 refresh
if (!refreshPromise) {
refreshPromise = apiRefresh().finally(() => {
refreshPromise = null;
});
}
const refreshed = await refreshPromise;
if (refreshed) {
// refresh 成功,重试原请求(CSRF cookie 可能已更新)
const newHeaders = clientHeaders();
const mergedInit = { ...init, credentials: "include" as const, headers: newHeaders };
res = await fetch(url, mergedInit);
}
}
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): Promise<CommentsResponse> {
const params = new URLSearchParams({ page: String(page), size: "20" });
const res = await fetch(`${API_BASE}/api/posts/${postId}/comments?${params}`, ssrInit());
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",
});
return res.json();
}
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 }),
});
return res.json();
}
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 || "删除公告失败");
}
}
// ===== 站点外观设置 =====
export async function apiGetSettings(): Promise<PublicSettings> {
const res = await fetchWithRefresh("/api/settings", { method: "GET" });
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 ?? "" };
}