路由系統
AppFuse Web 使用 React Router v7 建構路由系統,採用分層架構支援認證防衛、角色控制和巢狀路由。
架構概覽
路由配置
基本結構
// src/routes/index.tsx
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
export function Routes() {
const router = useMemo(
() =>
createBrowserRouter([
// 公開路由
{ path: '/login', element: <LoginPage /> },
{ path: '/403', element: <ForbiddenPage /> },
// 受保護路由
{
path: '/',
element: (
<ProtectedRoute>
<MainLayout />
</ProtectedRoute>
),
errorElement: <ErrorPage />,
children: [
{ index: true, element: <DashboardApplet /> },
{
path: 'orders/*',
element: (
<RoleGuard allowedRoles={['ROLE_OWNER', 'ROLE_SALES']}>
<OrderApplet />
</RoleGuard>
),
},
{ path: '*', element: <NotFoundPage /> },
],
},
// 全域 catch-all
{ path: '*', element: <NotFoundPage /> },
]),
[]
);
return <RouterProvider router={router} />;
}
路由防衛
ProtectedRoute - 認證防衛
檢查使用者是否已登入:
// src/routes/protected-route.tsx
import { Navigate, useLocation } from 'react-router-dom';
import { useAppSelector } from '@/store';
import { selectAuthorization } from '@/features/iam';
export function ProtectedRoute({ children }: { children: ReactNode }) {
const location = useLocation();
const authorization = useAppSelector(selectAuthorization);
const isAuthenticated = !!authorization?.access_token;
if (!isAuthenticated) {
// 保存原始位置,登入後返回
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
RoleGuard - 角色防衛
在 Applet 層級檢查角色權限:
// src/routes/role-guard.tsx
import { Navigate } from 'react-router-dom';
import { useAppSelector } from '@/store';
import { selectCurrentUser } from '@/features/iam';
interface RoleGuardProps {
allowedRoles: string[];
children: ReactNode;
}
export function RoleGuard({ allowedRoles, children }: RoleGuardProps) {
const currentUser = useAppSelector(selectCurrentUser);
const hasPermission =
allowedRoles.length === 0 ||
currentUser?.roles.some((role) => allowedRoles.includes(role));
if (!hasPermission) {
return <Navigate to="/403" replace />;
}
return children;
}
防衛層次
| 層級 | 檢查內容 | 失敗導向 | 用途 |
|---|---|---|---|
| ProtectedRoute | access_token | /login | 全域認證 |
| RoleGuard | roles 陣列 | /403 | Applet 授權 |
| 後端 API | Spring Security | 401/403 | 最終把關 |
重要
前端權限檢查僅用於 UX 優化(隱藏無權限的選單、避免進入無權限頁面)。真正的權限控制由後端 API 執行。
Applet 路由模式
每個 Applet 都遵循相同的路由模式,使用 AppletShell 提供統一的外殼。
Applet 結構
// src/applets/order-applet/order-applet.tsx
import { Routes, Route } from 'react-router-dom';
import { AppletShell, AppletAction } from '@/components/applet-shell';
export function OrderApplet() {
const actions: AppletAction[] = useMemo(
() => [
{ path: '/orders', icon: List, label: t('Orders') },
{ path: '/orders/new', icon: Plus, label: t('Create Order') },
],
[]
);
return (
<AppletShell basePath="/orders" actions={actions}>
<Routes>
<Route index element={<OrderFinder />} />
<Route path="new" element={<OrderEditor />} />
<Route path=":id/edit" element={<OrderEditor />} />
<Route path=":id" element={<OrderDetail />} />
</Routes>
</AppletShell>
);
}
URL 對應
| URL | 元件 | 說明 |
|---|---|---|
/orders | OrderFinder | 列表頁(搜尋、篩選) |
/orders/new | OrderEditor | 新增頁 |
/orders/:id | OrderDetail | 詳情頁 |
/orders/:id/edit | OrderEditor | 編輯頁 |
導航 Hooks
useNavigate - 程式化導航
import { useNavigate } from 'react-router-dom';
function OrderActions() {
const navigate = useNavigate();
const handleCreate = () => {
navigate('/orders/new');
};
const handleEdit = (id: string) => {
navigate(`/orders/${id}/edit`);
};
const handleBack = () => {
navigate(-1); // 返回上一頁
};
const handleLogin = () => {
navigate('/login', { replace: true }); // 不添加歷史記錄
};
}
useParams - 路由參數
import { useParams } from 'react-router-dom';
function OrderDetail() {
const { id } = useParams<{ id: string }>();
// URL: /orders/123 → id = '123'
return <div>Order ID: {id}</div>;
}
useSearchParams - 查詢字串
import { useSearchParams } from 'react-router-dom';
function OrderFinder() {
const [searchParams, setSearchParams] = useSearchParams();
// 讀取參數
const page = searchParams.get('page') || '1';
const status = searchParams.get('status');
// 設定參數
const handlePageChange = (newPage: number) => {
setSearchParams((prev) => {
prev.set('page', String(newPage));
return prev;
});
};
// 清除參數
const handleClearFilters = () => {
setSearchParams({});
};
}
useLocation - 當前位置
import { useLocation } from 'react-router-dom';
function Breadcrumb() {
const location = useLocation();
// location.pathname: '/orders/123'
// location.search: '?status=active'
// location.state: { from: '/dashboard' }
return <nav>{/* 根據 pathname 渲染麵包屑 */}</nav>;
}
佈局元件
MainLayout
主佈局,包含頭部和內容區域:
// src/layouts/main-layout.tsx
import { Outlet } from 'react-router-dom';
export function MainLayout() {
return (
<div className="flex flex-col h-screen overflow-hidden">
<Header />
<main className="flex-1 overflow-hidden">
<Outlet /> {/* 子路由內容注入點 */}
</main>
</div>
);
}
AppletShell
Applet 共用外殼,提供側邊工具列:
// src/components/applet-shell/applet-shell.tsx
export function AppletShell({
basePath,
actions,
children,
}: AppletShellProps) {
const location = useLocation();
const navigate = useNavigate();
const isActive = (path: string) => {
if (path === basePath) {
return location.pathname === path;
}
return location.pathname.startsWith(path);
};
return (
<div className="flex h-full">
{/* 側邊工具列 */}
<aside className="w-16 sticky top-0">
{actions.map((action) => (
<button
key={action.path}
onClick={() => navigate(action.path)}
className={isActive(action.path) ? 'active' : ''}
>
<action.icon />
</button>
))}
</aside>
{/* 主內容區域 */}
<div className="flex-1 overflow-auto">
{children}
</div>
</div>
);
}
錯誤頁面
ErrorPage - 路由層錯誤
// src/pages/error-page.tsx
import { useRouteError } from 'react-router-dom';
export function ErrorPage() {
const error = useRouteError();
return (
<div className="error-container">
<h1>Oops!</h1>
<p>Something went wrong.</p>
<button onClick={() => window.location.reload()}>
Refresh Page
</button>
<button onClick={() => navigate('/')}>
Go Home
</button>
</div>
);
}
ForbiddenPage - 403
// src/pages/forbidden-page.tsx
export function ForbiddenPage() {
const navigate = useNavigate();
return (
<div>
<h1>Access Denied</h1>
<p>You don't have permission to access this page.</p>
<button onClick={() => navigate(-1)}>Go Back</button>
<button onClick={() => navigate('/')}>Go Home</button>
</div>
);
}
NotFoundPage - 404
// src/pages/not-found-page.tsx
export function NotFoundPage() {
return (
<div>
<h1>Page Not Found</h1>
<p>The page you're looking for doesn't exist.</p>
<button onClick={() => navigate('/')}>Go Home</button>
</div>
);
}
認證流程
登入流程
Token 恢復
應用啟動時自動恢復登入狀態:
// src/App.tsx
useEffect(() => {
async function restoreSession() {
if (authorization?.access_token && !currentUser) {
try {
await dispatch(fetchCurrentUser()).unwrap();
} catch (error) {
// Token 過期,清除並重新登入
localStorage.removeItem('access_token');
dispatch(logout());
}
}
}
restoreSession();
}, []);
進階用法
查詢字串 vs 路由參數
| 用途 | 使用方式 | 範例 |
|---|---|---|
| 識別資源 | 路由參數 | /orders/:id |
| 過濾/分頁 | 查詢字串 | /orders?status=active&page=2 |
| 臨時狀態 | 查詢字串 | /orders/new?draftId=xxx |
程式碼分割
使用 lazy 和 Suspense 延遲載入:
import { lazy, Suspense } from 'react';
const OrderApplet = lazy(() => import('@/applets/order-applet'));
// 在路由配置中
{
path: 'orders/*',
element: (
<Suspense fallback={<Loading />}>
<OrderApplet />
</Suspense>
),
}
Basename 支援
部署在子目錄時設定 basename:
const basename = import.meta.env.VITE_BASE_URL || '/';
const router = createBrowserRouter([...], { basename });
最佳實踐
- Applet 遵循相同模式 - 使用 AppletShell 提供一致的佈局
- 權限分層檢查 - 前端用於 UX,後端用於安全
- 善用查詢字串 - 支援書籤和頁面刷新後狀態保留
- 錯誤邊界 - 使用 errorElement 捕獲路由層錯誤
- 程式碼分割 - 使用 lazy loading 優化首屏載入