feat(web): 构建漂流瓶移动端 PWA
- 演示登录、内存令牌、单 Promise 刷新与路由守卫\n- 投瓶、捞瓶、首次回复、会话与实时聊天\n- 独立管理员登录与审核/举报工作台\n- Socket 认证续期、断线补齐与账号切换清理\n- PWA 离线壳、深海主题与移动端无障碍
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
export interface ApiEnvelope<T> {
|
||||
code: string;
|
||||
message: string;
|
||||
data: T;
|
||||
requestId: string;
|
||||
}
|
||||
export interface ApiFailure {
|
||||
code: string;
|
||||
message: string;
|
||||
status: number | undefined;
|
||||
requestId: string | undefined;
|
||||
}
|
||||
export interface ApiClientOptions {
|
||||
fetcher?: typeof fetch;
|
||||
baseUrl?: string;
|
||||
getToken: () => string | null;
|
||||
setToken: (token: string | null) => void;
|
||||
onUnauthorized?: () => void;
|
||||
}
|
||||
|
||||
export class ApiError extends Error implements ApiFailure {
|
||||
public status: number | undefined;
|
||||
public requestId: string | undefined;
|
||||
constructor(
|
||||
public code: string,
|
||||
message: string,
|
||||
status?: number,
|
||||
requestId?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.requestId = requestId;
|
||||
}
|
||||
}
|
||||
|
||||
export function createApiClient(options: ApiClientOptions) {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const baseUrl = options.baseUrl ?? "/api/v1";
|
||||
let refreshing: Promise<string> | null = null;
|
||||
let failedRefresh: { token: string | null; error: unknown } | null = null;
|
||||
|
||||
async function refresh(
|
||||
staleToken: string | null = options.getToken(),
|
||||
): Promise<string> {
|
||||
// 同一认证失败周期内:只要令牌仍是失败时的令牌(或已被清空),
|
||||
// 就直接抛出已记录的失败,不再请求 refresh 接口或重复触发登出。
|
||||
if (
|
||||
failedRefresh !== null &&
|
||||
(staleToken === failedRefresh.token || staleToken === null)
|
||||
)
|
||||
throw failedRefresh.error;
|
||||
if (!refreshing) {
|
||||
refreshing = (async () => {
|
||||
try {
|
||||
const response = await fetcher(`${baseUrl}/auth/token/refresh`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
const body = (await response.json()) as ApiEnvelope<{
|
||||
accessToken: string;
|
||||
}>;
|
||||
if (!response.ok)
|
||||
throw new ApiError(
|
||||
body.code,
|
||||
body.message,
|
||||
response.status,
|
||||
body.requestId,
|
||||
);
|
||||
options.setToken(body.data.accessToken);
|
||||
failedRefresh = null;
|
||||
return body.data.accessToken;
|
||||
} catch (error) {
|
||||
const failure = networkError(error);
|
||||
failedRefresh = { token: staleToken, error: failure };
|
||||
options.setToken(null);
|
||||
options.onUnauthorized?.();
|
||||
throw failure;
|
||||
} finally {
|
||||
refreshing = null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return refreshing;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
retry = true,
|
||||
): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
const token = options.getToken();
|
||||
if (token) headers.set("authorization", `Bearer ${token}`);
|
||||
if (init.body && !headers.has("content-type"))
|
||||
headers.set("content-type", "application/json");
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
} catch (error) {
|
||||
throw networkError(error);
|
||||
}
|
||||
const body = (await response.json()) as ApiEnvelope<T>;
|
||||
if (response.status === 401 && retry && path !== "/auth/token/refresh") {
|
||||
await refresh(token);
|
||||
return request<T>(path, init, false);
|
||||
}
|
||||
if (!response.ok)
|
||||
throw new ApiError(
|
||||
body.code,
|
||||
body.message,
|
||||
response.status,
|
||||
body.requestId,
|
||||
);
|
||||
return body.data;
|
||||
}
|
||||
return {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown, headers?: HeadersInit) =>
|
||||
request<T>(path, {
|
||||
method: "POST",
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
...(headers === undefined ? {} : { headers }),
|
||||
}),
|
||||
put: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
|
||||
patch: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
function networkError(error: unknown): ApiError {
|
||||
if (error instanceof ApiError) return error;
|
||||
return new ApiError(
|
||||
"NETWORK_ERROR",
|
||||
error instanceof Error ? error.message : "网络连接失败",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user