feat: 新增管理员用户管理功能与账号封禁体系
此提交完成了全站管理员后台核心功能开发: 1. 新增AdminUserService与管理接口,支持用户列表查询、角色变更与封禁解封 2. 重构认证链路,统一账号封禁错误码与处理逻辑 3. 新增全局强制下线机制,账号被封禁时自动弹出告知弹窗并清理登录态 4. 重构管理员后台路由与布局,统一权限校验逻辑 5. 优化通知轮询逻辑,页面切回前台时立即刷新未读计数 6. 合并原公告、外观管理页到统一后台面板
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -27,3 +27,31 @@ export function onUserUpdate(handler: (patch: Partial<User>) => void): () => voi
|
||||
window.addEventListener(USER_UPDATED_EVENT, listener);
|
||||
return () => window.removeEventListener(USER_UPDATED_EVENT, listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制下线事件。
|
||||
*
|
||||
* 触发源统一收敛在 lib/api.ts:任何客户端请求收到后端的
|
||||
* code=account_banned(中间件/refresh/登录)或 /me 的 banned 标记时派发,
|
||||
* Header 负责复用登出清理链路(清 cookie、游客化、router.refresh),
|
||||
* AccountGuard 负责弹出封禁告知——状态清理与告知 UI 解耦,
|
||||
* 避免各请求调用方各自处理导致残留(参考强制下线单一入口原则)。
|
||||
*/
|
||||
export type ForceLogoutReason = "banned";
|
||||
|
||||
export const FORCE_LOGOUT_EVENT = "j13:force-logout";
|
||||
|
||||
export function emitForceLogout(reason: ForceLogoutReason = "banned"): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new CustomEvent(FORCE_LOGOUT_EVENT, { detail: { reason } }));
|
||||
}
|
||||
|
||||
export function onForceLogout(handler: (reason: ForceLogoutReason) => void): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
const listener = (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ reason: ForceLogoutReason }>).detail;
|
||||
handler(detail?.reason ?? "banned");
|
||||
};
|
||||
window.addEventListener(FORCE_LOGOUT_EVENT, listener);
|
||||
return () => window.removeEventListener(FORCE_LOGOUT_EVENT, listener);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user