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 联调说明
这个提交包含在:
2026-02-07 16:43:57 +08:00
父节点 f527633716
当前提交 c290e03fef
共修改 35 个文件,包含 4127 行新增和 0 行删除
+155
查看文件
@@ -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;
}
+67
查看文件
@@ -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;
}