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 行删除
+68
查看文件
@@ -0,0 +1,68 @@
import type { PropsWithChildren } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions";
function FullPageLoading() {
return (
<div className="flex min-h-screen items-center justify-center bg-background-light text-secondary">
正在加载会话...
</div>
);
}
function toLoginRedirect(pathname: string, search: string, hash: string) {
const target = `${pathname}${search}${hash}`;
return `/login?redirect=${encodeURIComponent(target)}`;
}
export function RequireAuth({ children }: PropsWithChildren) {
const location = useLocation();
const { accessToken, me, ready } = useAuth();
if (!ready) {
return <FullPageLoading />;
}
if (!accessToken || !me) {
return (
<Navigate
to={toLoginRedirect(location.pathname, location.search, location.hash)}
replace
/>
);
}
return <>{children}</>;
}
export function GuestOnly({ children }: PropsWithChildren) {
const { accessToken, me, ready } = useAuth();
if (!ready) {
return <FullPageLoading />;
}
if (accessToken && me) {
return <Navigate to="/dashboard" replace />;
}
return <>{children}</>;
}
export function RequirePermission({
anyOf,
children,
}: PropsWithChildren<{ anyOf: string[] }>) {
const { permissions, ready } = useAuth();
if (!ready) {
return <FullPageLoading />;
}
if (!hasAnyPermission(permissions, anyOf)) {
return <Navigate to="/403" replace />;
}
return <>{children}</>;
}
+73
查看文件
@@ -0,0 +1,73 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { AppShellLayout } from "../layouts/AppShellLayout";
import { AuthLayout } from "../layouts/AuthLayout";
import { DashboardPage } from "../pages/DashboardPage";
import { ForbiddenPage } from "../pages/ForbiddenPage";
import { InvoicePoolPage } from "../pages/InvoicePoolPage";
import { LoginPage } from "../pages/LoginPage";
import { NotFoundPage } from "../pages/NotFoundPage";
import { ProfilePage } from "../pages/ProfilePage";
import { ReimbursementsPage } from "../pages/ReimbursementsPage";
import { WorkflowTasksPage } from "../pages/WorkflowTasksPage";
import { GuestOnly, RequireAuth, RequirePermission } from "./guards";
export function AppRouter() {
return (
<Routes>
<Route
path="/login"
element={
<GuestOnly>
<AuthLayout />
</GuestOnly>
}
>
<Route index element={<LoginPage />} />
</Route>
<Route
path="/"
element={
<RequireAuth>
<AppShellLayout />
</RequireAuth>
}
>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route
path="reimbursements"
element={
<RequirePermission anyOf={["expense.view_own", "expense.view_all"]}>
<ReimbursementsPage />
</RequirePermission>
}
/>
<Route
path="workflow/tasks"
element={
<RequirePermission anyOf={["expense.approve"]}>
<WorkflowTasksPage />
</RequirePermission>
}
/>
<Route
path="invoice-pool"
element={
<RequirePermission anyOf={["invoice.upload", "invoice.view_own", "invoice.manage"]}>
<InvoicePoolPage />
</RequirePermission>
}
/>
<Route path="settings/profile" element={<ProfilePage />} />
</Route>
<Route path="/403" element={<ForbiddenPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
);
}