fix: 统一管理后台 401 与加载失败处理

This commit is contained in:
Nixevol
2026-09-30 15:12:16 +08:00
parent eff4bd53be
commit 898b9d2934
17 changed files with 473 additions and 47 deletions
+1 -3
View File
@@ -22,9 +22,7 @@ async function run<T>(fn: () => Promise<T>, silent = false): Promise<T> {
try {
return await fn();
} catch (e) {
if (!silent && e instanceof ApiError) {
message.error(e.message);
} else if (!silent && e instanceof Error) {
if (!silent && e instanceof Error && !(e instanceof ApiError)) {
message.error(e.message);
}
throw e;
+1 -3
View File
@@ -33,9 +33,7 @@ async function run<T>(fn: () => Promise<T>, silent = false): Promise<T> {
try {
return await fn();
} catch (e) {
if (!silent && e instanceof ApiError) {
message.error(e.message);
} else if (!silent && e instanceof Error) {
if (!silent && e instanceof Error && !(e instanceof ApiError)) {
message.error(e.message);
}
throw e;
+78
View File
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError, requestAdmin, setUnauthorizedHandler } from "./http";
vi.mock("@/utils/notify", () => ({
message: {
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
},
}));
import { message } from "@/utils/notify";
describe("requestAdmin", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
setUnauthorizedHandler(null);
vi.mocked(message.error).mockClear();
});
afterEach(() => {
vi.unstubAllGlobals();
setUnauthorizedHandler(null);
});
it("401 跳转登录页且只提示一次", async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ ok: false, error: { code: "unauthorized", message: "未登录" } }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
const handler = vi.fn();
setUnauthorizedHandler(handler);
await expect(requestAdmin("/api/admin/overview")).rejects.toBeInstanceOf(ApiError);
await expect(requestAdmin("/api/admin/endpoints")).rejects.toBeInstanceOf(ApiError);
expect(handler).toHaveBeenCalled();
expect(vi.mocked(message.error).mock.calls.filter((c) => c[0] === "登录已过期")).toHaveLength(1);
});
it("改密 401 不自动登出", async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ ok: false, error: { code: "unauthorized", message: "旧密码错误" } }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
const handler = vi.fn();
setUnauthorizedHandler(handler);
await expect(
requestAdmin("/api/admin/password", { method: "POST", body: { old_password: "x", new_password: "yyyyyyyyyyyy" } }),
).rejects.toMatchObject({ message: "旧密码错误" });
expect(handler).not.toHaveBeenCalled();
});
it("网络错误显示中文", async () => {
vi.mocked(fetch).mockRejectedValue(new TypeError("Failed to fetch"));
await expect(requestAdmin("/api/admin/me")).rejects.toMatchObject({ message: "无法连接服务器" });
expect(message.error).toHaveBeenCalledWith("无法连接服务器");
});
it("429 提示稍后再试", async () => {
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ ok: false, error: { code: "rate_limited", message: "locked" } }), {
status: 429,
headers: { "Content-Type": "application/json" },
}),
);
await expect(requestAdmin("/api/admin/login", { method: "POST", body: {} })).rejects.toMatchObject({
message: "请求过于频繁,请稍后再试",
});
expect(message.error).toHaveBeenCalledWith("请求过于频繁,请稍后再试");
});
});
+74 -12
View File
@@ -27,9 +27,52 @@ export interface RequestOptions {
contentType?: string | null;
}
const noAutoLogout = new Set(["/api/admin/login", "/api/admin/password"]);
type UnauthorizedHandler = (redirectPath: string) => void;
let unauthorizedHandler: UnauthorizedHandler | null = null;
let expiredNotified = false;
export function setUnauthorizedHandler(handler: UnauthorizedHandler | null) {
unauthorizedHandler = handler;
}
function requestPath(path: string): string {
const q = path.indexOf("?");
return q >= 0 ? path.slice(0, q) : path;
}
function currentRedirect(): string {
if (typeof window === "undefined") {
return "/";
}
return `${window.location.pathname}${window.location.search}` || "/";
}
function notifyExpiredOnce() {
if (expiredNotified) {
return;
}
expiredNotified = true;
message.error("登录已过期");
window.setTimeout(() => {
expiredNotified = false;
}, 2000);
}
function handleUnauthorized(path: string, silent: boolean) {
if (noAutoLogout.has(requestPath(path))) {
return;
}
if (!silent) {
notifyExpiredOnce();
}
unauthorizedHandler?.(currentRedirect());
}
/**
* 管理接口请求封装:自动带 credentials 与 X-Nixmsg-Request: 1,错误直接 message 显示。
* W4 接真实后端时页面不必改,只需让 admin 模块走本函数。
* 管理接口请求封装:自动带 credentials 与 X-Nixmsg-Request: 1。
* 错误只在这里提示一次;401(登录/改密除外)清空会话并跳转登录页。
*/
export async function requestAdmin<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const method = opts.method ?? (opts.body != null || opts.rawBody != null ? "POST" : "GET");
@@ -46,19 +89,32 @@ export async function requestAdmin<T>(path: string, opts: RequestOptions = {}):
headers["Content-Type"] = opts.contentType;
}
const res = await fetch(path, {
method,
credentials: "include",
headers,
body: body ?? undefined,
});
let res: Response;
try {
res = await fetch(path, {
method,
credentials: "include",
headers,
body: body ?? undefined,
});
} catch {
const err = new ApiError("network", "无法连接服务器", 0);
if (!opts.silent) {
message.error(err.message);
}
throw err;
}
if (res.status === 401) {
handleUnauthorized(path, Boolean(opts.silent));
}
let envelope: ApiEnvelope<T>;
try {
envelope = (await res.json()) as ApiEnvelope<T>;
} catch {
const err = new ApiError("internal", `响应不是 JSON(HTTP ${res.status})`, res.status);
if (!opts.silent) {
if (!opts.silent && res.status !== 401) {
message.error(err.message);
}
throw err;
@@ -66,7 +122,7 @@ export async function requestAdmin<T>(path: string, opts: RequestOptions = {}):
if (typeof envelope !== "object" || envelope === null) {
const err = new ApiError("internal", "响应格式无效", res.status);
if (!opts.silent) {
if (!opts.silent && res.status !== 401) {
message.error(err.message);
}
throw err;
@@ -77,8 +133,14 @@ export async function requestAdmin<T>(path: string, opts: RequestOptions = {}):
}
const errBody: ApiErrorBody = envelope.error ?? { code: "internal", message: "未知错误" };
const err = new ApiError(errBody.code, errBody.message, res.status, "data" in envelope ? envelope.data : undefined);
if (!opts.silent) {
let display = errBody.message;
if (res.status === 429) {
display = "请求过于频繁,请稍后再试";
} else if (res.status === 401 && !noAutoLogout.has(requestPath(path))) {
display = "登录已过期";
}
const err = new ApiError(errBody.code, display, res.status, "data" in envelope ? envelope.data : undefined);
if (!opts.silent && res.status !== 401) {
message.error(err.message);
}
throw err;