fix: 统一管理后台 401 与加载失败处理
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user