refactor(web): 优化认证布局和权限检查

- auth layout 添加智能重定向(检查权限后重定向到可访问页面)
- dashboard layout 优化权限加载和过期检查逻辑
- 支持权限缓存过期后自动刷新

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
charilezhou
2026-01-17 21:40:57 +08:00
parent 77bf31146d
commit d7c4cbc98d
2 changed files with 109 additions and 42 deletions

View File

@@ -1,37 +1,111 @@
'use client';
import type { MenuTreeNode } from '@seclusion/shared';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
import { ThemeToggle } from '@/components/layout/ThemeToggle';
import { siteConfig } from '@/config/site';
import { getUserMenusAndPermissions } from '@/services/permission.service';
import { useAuthStore, usePermissionStore } from '@/stores';
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
function AuthRedirect() {
const router = useRouter();
const searchParams = useSearchParams();
const { token, isHydrated: authHydrated } = useAuthStore();
const { isLoaded, isExpired, setPermissionData, canAccessRoute, menus } = usePermissionStore();
const [hasRedirected, setHasRedirected] = useState(false);
// 加载权限数据
useEffect(() => {
if (!authHydrated || !token) return;
if (!isLoaded || isExpired()) {
getUserMenusAndPermissions()
.then((data) => {
setPermissionData(data);
})
.catch((error) => {
console.error('加载权限数据失败:', error);
setPermissionData({ menus: [], permissions: [], isSuperAdmin: false });
});
}
}, [authHydrated, token, isLoaded, isExpired, setPermissionData]);
// 执行智能重定向(只执行一次)
useEffect(() => {
// 未登录、权限未加载或已经执行过重定向,则不处理
if (!authHydrated || !token || !isLoaded || hasRedirected) return;
const targetRedirect = searchParams.get('redirect') || '/dashboard';
// 检查目标路由是否可访问
if (canAccessRoute(targetRedirect)) {
setHasRedirected(true);
router.replace(targetRedirect);
return;
}
// 目标路由不可访问,找到第一个可访问的菜单路径
const findFirstAccessiblePath = (menuList: MenuTreeNode[]): string | null => {
for (const menu of menuList) {
// 跳过隐藏菜单、外部链接和没有路径的菜单
if (!menu.isHidden && menu.path && !menu.isExternal) {
return menu.path;
}
// 递归查找子菜单
if (menu.children && menu.children.length > 0) {
const childPath = findFirstAccessiblePath(menu.children);
if (childPath) return childPath;
}
}
return null;
};
const firstPath = findFirstAccessiblePath(menus);
setHasRedirected(true);
if (firstPath) {
router.replace(firstPath);
} else {
// 没有任何可访问的路由,跳转到无权限页面
router.replace('/403');
}
}, [authHydrated, token, isLoaded, hasRedirected, searchParams, canAccessRoute, menus, router]);
return null;
}
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen flex flex-col">
{/* 顶部导航 */}
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 items-center justify-between">
<Link href="/" className="font-bold text-xl">
{siteConfig.name}
</Link>
<ThemeToggle />
</div>
</header>
<>
<Suspense fallback={null}>
<AuthRedirect />
</Suspense>
<div className="min-h-screen flex flex-col">
{/* 顶部导航 */}
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 items-center justify-between">
<Link href="/" className="font-bold text-xl">
{siteConfig.name}
</Link>
<ThemeToggle />
</div>
</header>
{/* 主内容区 */}
<main className="flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md">{children}</div>
</main>
{/* 主内容区 */}
<main className="flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md">{children}</div>
</main>
{/* 页脚 */}
<footer className="border-t py-4">
<div className="container text-center text-sm text-muted-foreground">
&copy; {new Date().getFullYear()} {siteConfig.name}. All rights
reserved.
</div>
</footer>
</div>
{/* 页脚 */}
<footer className="border-t py-4">
<div className="container text-center text-sm text-muted-foreground">
&copy; {new Date().getFullYear()} {siteConfig.name}. All rights reserved.
</div>
</footer>
</div>
</>
);
}

View File

@@ -5,7 +5,6 @@ import { useEffect } from 'react';
import { Header } from '@/components/layout/Header';
import { Sidebar, MobileSidebar } from '@/components/layout/Sidebar';
import { getRoutePermission, isPublicRoute } from '@/config/route-permissions';
import { getLoginUrl } from '@/lib/auth';
import { getUserMenusAndPermissions } from '@/services/permission.service';
import { useAuthStore, usePermissionStore } from '@/stores';
@@ -14,7 +13,8 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
const pathname = usePathname();
const router = useRouter();
const { token, isHydrated: authHydrated } = useAuthStore();
const { isLoaded, setPermissionData, hasPermission, clearPermissionData } = usePermissionStore();
const { isLoaded, isExpired, setPermissionData, canAccessRoute, clearPermissionData } =
usePermissionStore();
// 登录状态检查:未登录时重定向到登录页
useEffect(() => {
@@ -26,8 +26,8 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
return;
}
// 已登录且权限未加载时,加载权限数据
if (!isLoaded) {
// 已登录且权限未加载或已过期时,加载/刷新权限数据
if (!isLoaded || isExpired()) {
getUserMenusAndPermissions()
.then((data) => {
setPermissionData(data);
@@ -38,24 +38,17 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
setPermissionData({ menus: [], permissions: [], isSuperAdmin: false });
});
}
}, [authHydrated, token, isLoaded, pathname, router, setPermissionData, clearPermissionData]);
}, [authHydrated, token, isLoaded, isExpired, pathname, router, setPermissionData, clearPermissionData]);
// 路由权限检查
useEffect(() => {
if (!authHydrated || !isLoaded) return;
// 公开路由不检查权限
if (isPublicRoute(pathname)) return;
// 获取路由所需权限
const requiredPermission = getRoutePermission(pathname);
if (!requiredPermission) return;
// 检查权限
if (!hasPermission(requiredPermission)) {
// 检查是否可以访问当前路由
if (!canAccessRoute(pathname)) {
router.replace('/403');
}
}, [authHydrated, isLoaded, pathname, hasPermission, router]);
}, [authHydrated, isLoaded, pathname, canAccessRoute, router]);
return (
<div className="min-h-screen flex">