feat(web): 构建漂流瓶移动端 PWA

- 演示登录、内存令牌、单 Promise 刷新与路由守卫\n- 投瓶、捞瓶、首次回复、会话与实时聊天\n- 独立管理员登录与审核/举报工作台\n- Socket 认证续期、断线补齐与账号切换清理\n- PWA 离线壳、深海主题与移动端无障碍
This commit is contained in:
root
2026-09-17 22:19:24 +08:00
parent fa0fa78312
commit 7ca855f19c
42 changed files with 8498 additions and 2900 deletions
+142
View File
@@ -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 : "网络连接失败",
);
}