106 lines
4.5 KiB
TypeScript
106 lines
4.5 KiB
TypeScript
import type { ApiErrorResponse, ApiResponse, AuthPayload, AdminProfile, Dashboard, Lead, SiteConfig, WanfaCategory, ConciergeAdvisor } from "@/types";
|
|
|
|
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
|
let accessToken: string | null = null;
|
|
let refreshPromise: Promise<AuthPayload> | null = null;
|
|
|
|
export class ApiRequestError extends Error {
|
|
constructor(
|
|
message: string,
|
|
readonly statusCode: number,
|
|
readonly errorCode?: string,
|
|
readonly details?: unknown,
|
|
) {
|
|
super(message);
|
|
this.name = "ApiRequestError";
|
|
}
|
|
}
|
|
|
|
export class AuthExpiredError extends ApiRequestError {
|
|
constructor(message = "登录已失效,请重新登录") {
|
|
super(message, 401, "AUTH_EXPIRED");
|
|
this.name = "AuthExpiredError";
|
|
}
|
|
}
|
|
|
|
export function setAccessToken(token: string | null) {
|
|
accessToken = token;
|
|
}
|
|
|
|
export function getAccessToken() {
|
|
return accessToken;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
async function parseJson(response: Response): Promise<unknown> {
|
|
return response.json().catch(() => null);
|
|
}
|
|
|
|
function unwrap<T>(payload: unknown, statusCode: number): T {
|
|
if (!isRecord(payload) || typeof payload.code !== "number" || payload.code !== statusCode || typeof payload.msg !== "string" || !Object.prototype.hasOwnProperty.call(payload, "data")) {
|
|
throw new ApiRequestError("接口响应格式不正确", statusCode, "API_PROTOCOL_ERROR");
|
|
}
|
|
if (statusCode < 200 || statusCode >= 300) {
|
|
if (payload.data !== null) throw new ApiRequestError("接口响应格式不正确", statusCode, "API_PROTOCOL_ERROR");
|
|
const error = payload as unknown as ApiErrorResponse;
|
|
throw new ApiRequestError(error.msg, statusCode, error.errorCode, error.details);
|
|
}
|
|
if (payload.msg !== "success") throw new ApiRequestError("接口响应格式不正确", statusCode, "API_PROTOCOL_ERROR");
|
|
return payload.data as T;
|
|
}
|
|
|
|
async function rawRequest(path: string, options: RequestInit = {}): Promise<Response> {
|
|
const headers = new Headers(options.headers);
|
|
if (!(options.body instanceof FormData)) headers.set("Content-Type", "application/json");
|
|
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
|
|
return fetch(`${API_BASE}${path}`, { ...options, headers, credentials: "include" });
|
|
}
|
|
|
|
export async function refreshAccessToken(): Promise<AuthPayload> {
|
|
const response = await rawRequest("/api/admin/auth/refresh", { method: "POST" });
|
|
const payload = unwrap<AuthPayload>(await parseJson(response), response.status);
|
|
setAccessToken(payload.accessToken || payload.token);
|
|
return payload;
|
|
}
|
|
|
|
async function refreshOnce() {
|
|
if (!refreshPromise) {
|
|
refreshPromise = refreshAccessToken().finally(() => {
|
|
refreshPromise = null;
|
|
});
|
|
}
|
|
return refreshPromise;
|
|
}
|
|
|
|
export async function request<T>(path: string, options: RequestInit = {}, retry = true): Promise<T> {
|
|
const response = await rawRequest(path, options);
|
|
if (response.status === 401 && retry && !path.includes("/auth/login") && !path.includes("/auth/refresh") && !path.includes("/auth/logout")) {
|
|
try {
|
|
await refreshOnce();
|
|
return request<T>(path, options, false);
|
|
} catch {
|
|
setAccessToken(null);
|
|
throw new AuthExpiredError();
|
|
}
|
|
}
|
|
return unwrap<T>(await parseJson(response), response.status);
|
|
}
|
|
|
|
export const login = (email: string, password: string) => request<AuthPayload>("/api/admin/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }, false);
|
|
export const getProfile = () => request<AdminProfile>("/api/admin/system/profile");
|
|
export const logout = () => request<{ ok: boolean }>("/api/admin/auth/logout", { method: "POST" }, false);
|
|
export const getDashboard = () => request<Dashboard>("/api/admin/dashboard");
|
|
export const getSiteConfig = () => request<SiteConfig>("/api/admin/site-config");
|
|
export const getWanfa = async () => (await request<{ categories: WanfaCategory[] }>("/api/admin/wanfa/categories")).categories;
|
|
export const getConcierge = async () => (await request<{ advisors: ConciergeAdvisor[] }>("/api/admin/concierge/advisors")).advisors;
|
|
export const getLeads = () => request<{ items: Lead[] }>("/api/admin/leads?take=50");
|
|
export const updateLeadStatus = (leadId: string, status: string) =>
|
|
request<Lead>(`/api/admin/leads/${leadId}/status`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ status }),
|
|
});
|
|
export const getSystemResource = (resource: string) => request<{ items: unknown[] }>(`/api/admin/system/${resource}`);
|