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,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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户