feat: 新增管理员用户管理功能与账号封禁体系

此提交完成了全站管理员后台核心功能开发:
1.  新增AdminUserService与管理接口,支持用户列表查询、角色变更与封禁解封
2.  重构认证链路,统一账号封禁错误码与处理逻辑
3.  新增全局强制下线机制,账号被封禁时自动弹出告知弹窗并清理登录态
4.  重构管理员后台路由与布局,统一权限校验逻辑
5.  优化通知轮询逻辑,页面切回前台时立即刷新未读计数
6.  合并原公告、外观管理页到统一后台面板
This commit is contained in:
2026-09-15 02:52:26 +08:00
parent 24c6dea38c
commit 6da4309453
20 changed files with 2248 additions and 105 deletions

View File

@@ -1,4 +1,5 @@
import { CSRF_COOKIE } from "./cookies";
import { emitForceLogout } from "./userEvents";
// API 基础配置
// 注意:客户端请求使用相对路径 /api/*,走 Next.js rewrite 代理到后端,
@@ -35,6 +36,9 @@ export interface User {
export interface MeResponse {
user: User | null;
unread_count: number;
// 后端 OptionalAuth 识别到凭据所属账号已封禁时携带(HTTP 仍 200,不阻断页面)
banned?: boolean;
code?: string;
}
export interface Board {
@@ -279,25 +283,51 @@ function clientHeaders(extra: Record<string, string> = {}): HeadersInit {
// ===== Refresh token 自动续期 =====
let refreshPromise: Promise<boolean> | null = null;
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<boolean> {
async function apiRefresh(): Promise<{ ok: boolean; banned: boolean }> {
try {
const res = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include",
headers: clientHeaders(),
});
return res.ok;
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 false;
return { ok: false, banned: false };
}
}
// 带自动续期的 fetch:遇到 401 时尝试 refresh,成功后重试原请求
// 带自动续期的 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
@@ -306,12 +336,16 @@ async function fetchWithRefresh(url: string, init: RequestInit): Promise<Respons
refreshPromise = null;
});
}
const refreshed = await refreshPromise;
if (refreshed) {
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;
@@ -430,7 +464,13 @@ export async function apiMe(): Promise<MeResponse> {
credentials: "include",
cache: "no-store",
});
return res.json();
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) {
@@ -440,7 +480,10 @@ export async function apiLogin(username: string, password: string) {
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ username, password }),
});
return res.json();
const data = await res.json();
// 新登录态开始:复位封禁通知去重(解封后再次被封仍可提示)
if (res.ok && data?.user) bannedNotified = false;
return data;
}
export async function apiRegister(username: string, email: string, password: string) {
@@ -739,6 +782,110 @@ export async function apiAdminDeleteAnnouncement(id: number): Promise<void> {
}
}
// ===== 用户管理(管理员) =====
// 后台用户列表项:email/最近活跃仅管理员接口返回
export interface AdminUser {
id: number;
username: string;
nickname: string;
email: string;
avatar: string;
signature: string;
role: "user" | "admin" | string;
banned: boolean;
post_count: number;
comment_count: number;
created_at: string;
last_seen_at: string | null;
}
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();
}
// 客户端后台用户列表(走 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" | "admin"
export async function apiAdminSetUserRole(
id: number,
role: "user" | "admin"
): Promise<{ user: AdminUser }> {
const res = await fetchWithRefresh(`/api/admin/users/${id}/role`, {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ role }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "角色更新失败");
return data as { user: AdminUser };
}
// 封禁 / 解封用户
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> {