Files
jiang13-bbs/frontend/lib/api.ts
freefire bee06fa962 feat: 完成社区首页功能迭代,新增首页聚合与工作台组件
本次提交实现了完整的社区首页功能:
1. 新增首页聚合接口与服务,包含社区统计、热门帖、活跃用户与板块数据
2. 实现帖子推荐排序、SSR缓存与点赞态同步逻辑
3. 重构导航与布局系统,新增工作台侧边栏与移动端适配
4. 优化主题色与UI样式,统一设计系统风格
5. 修复登录注册页自动跳转与组件状态同步问题
2026-09-12 23:36:18 +08:00

474 lines
13 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 响应:用户信息 + 未读通知数(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;
}
export interface ActiveUser {
id: number;
username: string;
nickname: string;
avatar: string;
activity_count: number;
}
export interface BoardWithCount extends Board {
post_count: number;
}
export interface OverviewResponse {
stats: OverviewStats;
hot: PostListItem[];
active_users: ActiveUser[];
boards: BoardWithCount[];
}
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;
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;
content: string;
status: string;
created_at: string;
user: User;
}
export interface PostsResponse {
posts: PostListItem[];
total: number;
page: number;
size: number;
}
export interface UserProfile {
user: {
id: number;
username: string;
nickname: string;
avatar: string;
role: string;
created_at: string;
};
stats: {
post_count: number;
comment_count: 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" | "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: Board[] }> {
const res = await fetch(`${API_BASE}/api/boards`, ssrInit());
if (!res.ok) throw new Error("获取板块失败");
return res.json();
}
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): Promise<{ comments: Comment[] }> {
const res = await fetch(`${API_BASE}/api/posts/${postId}/comments`, 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();
}
// ===== 客户端 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) {
const res = await fetchWithRefresh(`/api/posts/${postId}/comments`, {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ 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 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();
}