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 行删除
+143
查看文件
@@ -0,0 +1,143 @@
import {
createContext,
type PropsWithChildren,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { configureHttpAuth } from "../api/http";
import type { AuthState, MeResponse } from "../api/types";
import {
fetchCurrentEmployee,
loginWithPassword,
logoutSession,
refreshSession,
} from "./authService";
import { resolvePermissionsForRoles } from "./permissions";
type AuthContextValue = AuthState & {
login: (identifier: string, password: string) => Promise<void>;
logout: () => Promise<void>;
bootstrapByRefresh: () => Promise<void>;
clearSession: () => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);
const EMPTY_STATE: AuthState = {
accessToken: null,
me: null,
permissions: [],
ready: false,
};
function buildStateWithUser(accessToken: string, me: MeResponse): AuthState {
return {
accessToken,
me,
permissions: resolvePermissionsForRoles(me.roleCodes ?? []),
ready: true,
};
}
export function AuthProvider({ children }: PropsWithChildren) {
const [state, setState] = useState<AuthState>(EMPTY_STATE);
const tokenRef = useRef<string | null>(null);
useEffect(() => {
tokenRef.current = state.accessToken;
}, [state.accessToken]);
const clearSession = useCallback(() => {
tokenRef.current = null;
setState((previous) => ({
...previous,
accessToken: null,
me: null,
permissions: [],
ready: true,
}));
}, []);
const commitAuthenticatedUser = useCallback((accessToken: string, me: MeResponse) => {
tokenRef.current = accessToken;
setState(buildStateWithUser(accessToken, me));
}, []);
const bootstrapByRefresh = useCallback(async () => {
try {
const refreshed = await refreshSession();
const me = await fetchCurrentEmployee(refreshed.accessToken);
commitAuthenticatedUser(refreshed.accessToken, me);
} catch {
clearSession();
}
}, [clearSession, commitAuthenticatedUser]);
const login = useCallback(
async (identifier: string, password: string) => {
const result = await loginWithPassword({ identifier, password });
const me = await fetchCurrentEmployee(result.accessToken);
commitAuthenticatedUser(result.accessToken, me);
},
[commitAuthenticatedUser],
);
const logout = useCallback(async () => {
try {
await logoutSession();
} finally {
clearSession();
}
}, [clearSession]);
useEffect(() => {
configureHttpAuth({
getAccessToken: () => tokenRef.current,
refreshAccessToken: async () => {
try {
const refreshed = await refreshSession();
tokenRef.current = refreshed.accessToken;
setState((previous) => ({
...previous,
accessToken: refreshed.accessToken,
ready: true,
}));
return refreshed.accessToken;
} catch {
clearSession();
return null;
}
},
clearSession,
});
}, [clearSession]);
useEffect(() => {
void bootstrapByRefresh();
}, [bootstrapByRefresh]);
const value = useMemo<AuthContextValue>(
() => ({
...state,
login,
logout,
bootstrapByRefresh,
clearSession,
}),
[bootstrapByRefresh, clearSession, login, logout, state],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}