feat(client): 初始化前端壳与鉴权守卫(P1-5)
- 技术栈:React 18 + TypeScript + Vite + Tailwind - API 层:ApiError + 401 自动刷新 + single-flight 重试 - 鉴权:内存 Access Token + Cookie Refresh + 启动 bootstrap - RBAC:权限映射 + RequireAuth/RequirePermission 路由守卫 - 布局:OA 工作台壳层 + 侧边栏 + 5 个占位页面 - 样式:复用 hyb-web 视觉 token(单向同步) - 文档:新增 client README,补充 server 联调说明
这个提交包含在:
@@ -0,0 +1,155 @@
|
||||
import { ApiError, type ApiErrorBody, type ApiSuccess } from "./types";
|
||||
|
||||
type AccessTokenGetter = () => string | null;
|
||||
type RefreshAccessToken = () => Promise<string | null>;
|
||||
type ClearSession = () => void;
|
||||
|
||||
let getAccessToken: AccessTokenGetter = () => null;
|
||||
let refreshAccessToken: RefreshAccessToken | null = null;
|
||||
let clearSession: ClearSession | null = null;
|
||||
let refreshPromise: Promise<string | null> | null = null;
|
||||
|
||||
export type ConfigureHttpAuthOptions = {
|
||||
getAccessToken: AccessTokenGetter;
|
||||
refreshAccessToken: RefreshAccessToken;
|
||||
clearSession?: ClearSession;
|
||||
};
|
||||
|
||||
export type ApiRequestOptions = Omit<RequestInit, "headers"> & {
|
||||
headers?: HeadersInit;
|
||||
auth?: boolean;
|
||||
retryOn401?: boolean;
|
||||
accessTokenOverride?: string | null;
|
||||
};
|
||||
|
||||
export function configureHttpAuth(options: ConfigureHttpAuthOptions) {
|
||||
getAccessToken = options.getAccessToken;
|
||||
refreshAccessToken = options.refreshAccessToken;
|
||||
clearSession = options.clearSession ?? null;
|
||||
}
|
||||
|
||||
async function safeParseJson(response: Response): Promise<unknown> {
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildApiError(response: Response, payload: unknown): ApiError {
|
||||
const fallbackRequestId = response.headers.get("x-request-id") ?? "";
|
||||
|
||||
if (payload && typeof payload === "object" && "error" in payload) {
|
||||
const body = payload as ApiErrorBody;
|
||||
return new ApiError({
|
||||
status: response.status,
|
||||
code: body.error?.code ?? "SYS_001",
|
||||
message: body.error?.message ?? "请求失败",
|
||||
details: body.error?.details,
|
||||
requestId: body.requestId || fallbackRequestId,
|
||||
});
|
||||
}
|
||||
|
||||
return new ApiError({
|
||||
status: response.status,
|
||||
code: "SYS_001",
|
||||
message: "请求失败",
|
||||
requestId: fallbackRequestId,
|
||||
});
|
||||
}
|
||||
|
||||
async function getRefreshedToken(): Promise<string | null> {
|
||||
if (!refreshAccessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = refreshAccessToken()
|
||||
.catch(() => null)
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
async function sendRequest<T>(
|
||||
path: string,
|
||||
options: ApiRequestOptions,
|
||||
): Promise<ApiSuccess<T>> {
|
||||
const {
|
||||
auth = true,
|
||||
retryOn401 = true,
|
||||
accessTokenOverride,
|
||||
headers,
|
||||
...rest
|
||||
} = options;
|
||||
|
||||
const requestHeaders = new Headers(headers ?? {});
|
||||
const token = accessTokenOverride ?? (auth ? getAccessToken() : null);
|
||||
|
||||
if (auth && token) {
|
||||
requestHeaders.set("authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
if (rest.body && !(rest.body instanceof FormData) && !requestHeaders.has("content-type")) {
|
||||
requestHeaders.set("content-type", "application/json");
|
||||
}
|
||||
|
||||
const response = await fetch(path, {
|
||||
...rest,
|
||||
credentials: "include",
|
||||
headers: requestHeaders,
|
||||
});
|
||||
|
||||
if (response.status === 401 && auth && retryOn401 && refreshAccessToken) {
|
||||
const nextToken = await getRefreshedToken();
|
||||
if (nextToken) {
|
||||
return sendRequest<T>(path, {
|
||||
...options,
|
||||
retryOn401: false,
|
||||
accessTokenOverride: nextToken,
|
||||
});
|
||||
}
|
||||
|
||||
if (clearSession) {
|
||||
clearSession();
|
||||
}
|
||||
}
|
||||
|
||||
const payload = await safeParseJson(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw buildApiError(response, payload);
|
||||
}
|
||||
|
||||
if (payload && typeof payload === "object" && "data" in payload && "requestId" in payload) {
|
||||
return payload as ApiSuccess<T>;
|
||||
}
|
||||
|
||||
return {
|
||||
requestId: response.headers.get("x-request-id") ?? "",
|
||||
data: undefined as T,
|
||||
};
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(
|
||||
path: string,
|
||||
options: ApiRequestOptions = {},
|
||||
): Promise<ApiSuccess<T>> {
|
||||
return sendRequest<T>(path, options);
|
||||
}
|
||||
|
||||
export async function apiRequestData<T>(
|
||||
path: string,
|
||||
options: ApiRequestOptions = {},
|
||||
): Promise<T> {
|
||||
const response = await apiRequest<T>(path, options);
|
||||
return response.data;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
export type ApiSuccess<T> = {
|
||||
requestId: string;
|
||||
data: T;
|
||||
};
|
||||
|
||||
export type ApiErrorBody = {
|
||||
requestId: string;
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export type LoginRequest = {
|
||||
identifier: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type LoginResponse = {
|
||||
accessToken: string;
|
||||
};
|
||||
|
||||
export type MeResponse = {
|
||||
id: string;
|
||||
employeeNo: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
departmentId: string;
|
||||
directManagerId: string | null;
|
||||
status: string;
|
||||
roleCodes: string[];
|
||||
};
|
||||
|
||||
export type AuthState = {
|
||||
accessToken: string | null;
|
||||
me: MeResponse | null;
|
||||
permissions: string[];
|
||||
ready: boolean;
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
code: string;
|
||||
details?: Record<string, unknown>;
|
||||
requestId: string;
|
||||
|
||||
constructor(params: {
|
||||
status: number;
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
requestId?: string;
|
||||
}) {
|
||||
super(params.message);
|
||||
this.name = "ApiError";
|
||||
this.status = params.status;
|
||||
this.code = params.code;
|
||||
this.details = params.details;
|
||||
this.requestId = params.requestId ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
export function isApiError(value: unknown): value is ApiError {
|
||||
return value instanceof ApiError;
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户