Files
leaudit-platform-frontend/app/components/layout/Sidebar.tsx
T
2025-12-02 10:10:03 +08:00

434 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from '@remix-run/react';
import type { UserRole } from '~/root';
import { getUserRoutesByRole, mapUserRoleToRoleKey, type MenuItem } from '~/api/auth/user-routes';
import { DOCUMENT_URL } from '~/config/api-config';
interface SidebarProps {
onToggle: () => void;
collapsed: boolean;
userRole: UserRole;
frontendJWT?: string;
}
export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: SidebarProps) {
const location = useLocation();
const [expandedMenus, setExpandedMenus] = useState<Record<string, boolean>>({});
const [menuItems, setMenuItems] = useState<MenuItem[]>([]); // 动态菜单项
const [isLoadingRoutes, setIsLoadingRoutes] = useState<boolean>(true); // 路由加载状态
const [isMobile, setIsMobile] = useState<boolean>(false); // 移动端检测
const [selectedModuleName, setSelectedModuleName] = useState<string>(''); // 当前选中的模块名称
const [selectedModulePicPath, setSelectedModulePicPath] = useState<string>(''); // 当前选中的模块图片路径
const navigate = useNavigate();
// 移动端检测
useEffect(() => {
const checkMobile = () => {
const mobile = window.innerWidth <= 768; // 768px以下视为移动端
setIsMobile(mobile);
};
// 初始检测
checkMobile();
// 监听窗口大小变化
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
// 获取用户路由权限
useEffect(() => {
// console.log('🔍 [Sidebar] useEffect 触发,开始获取路由权限');
const fetchUserRoutes = async () => {
setIsLoadingRoutes(true);
try {
// 优先使用传入的 frontendJWT,否则从 localStorage 读取
let jwt = frontendJWT;
if (!jwt && typeof window !== 'undefined') {
jwt = localStorage.getItem('access_token') || '';
console.log('📖 [Sidebar] 从 localStorage 读取 JWT');
}
if (!jwt) {
console.error('❌ [Sidebar] JWT token 未找到,props.frontendJWT:', frontendJWT, 'localStorage.access_token:', localStorage.getItem('access_token'));
setMenuItems([]);
setIsLoadingRoutes(false);
return;
}
// console.log('🔍 [Sidebar] 当前用户角色:', userRole, 'JWT前20字符:', jwt.substring(0, 20));
// console.log('🔍 [Sidebar] 映射后的角色key:', roleKey);
const result = await getUserRoutesByRole(userRole, jwt);
if (result.success && result.data) {
setMenuItems(result.data);
// console.log('✅ [Sidebar] 用户路由权限加载成功:', result.data);
} else {
console.error('❌ [Sidebar] 获取用户路由权限失败:', result.error);
// 如果需要重定向到首页
if (result.shouldRedirectToHome) {
// console.log('🔄 [Sidebar] 重定向到首页');
navigate('/');
return;
}
// 其他错误情况,使用空数组
setMenuItems([]);
}
} catch (error) {
console.error('❌ [Sidebar] 获取用户路由权限时发生错误:', error);
// 发生异常时也重定向到首页
navigate('/');
return;
} finally {
setIsLoadingRoutes(false);
}
};
fetchUserRoutes();
}, [userRole, frontendJWT, navigate]);
// 🔑 检查是否处于系统设置模式或交叉评查模式
const [isSettingsMode, setIsSettingsMode] = useState<boolean>(false);
const [isCrossCheckingMode, setIsCrossCheckingMode] = useState<boolean>(false);
// 从 sessionStorage 读取当前选中的模块名称和图片路径,以及各种模式标志
useEffect(() => {
if (typeof window !== 'undefined') {
try {
const moduleName = sessionStorage.getItem('selectedModuleName');
const modulePicPath = sessionStorage.getItem('selectedModulePicPath');
const settingsMode = sessionStorage.getItem('settingsMode');
const crossCheckingMode = sessionStorage.getItem('crossCheckingMode');
if (moduleName) {
setSelectedModuleName(moduleName);
console.log('📌 [Sidebar] 当前选中模块:', moduleName);
}
if (modulePicPath) {
setSelectedModulePicPath(modulePicPath);
console.log('🖼️ [Sidebar] 模块图片路径:', modulePicPath);
}
// 🔑 检查是否处于系统设置模式
if (settingsMode === 'true') {
setIsSettingsMode(true);
setIsCrossCheckingMode(false); // 互斥
console.log('⚙️ [Sidebar] 进入系统设置模式');
}
// 🔑 检查是否处于交叉评查模式
else if (crossCheckingMode === 'true') {
setIsCrossCheckingMode(true);
setIsSettingsMode(false); // 互斥
console.log('🔀 [Sidebar] 进入交叉评查模式');
}
// 普通模式
else {
setIsSettingsMode(false);
setIsCrossCheckingMode(false);
}
} catch (error) {
console.error('❌ [Sidebar] 读取 sessionStorage 失败:', error);
}
}
}, [location.pathname]); // 路由变化时重新读取
// 初始化展开状态,默认全部展开
useEffect(() => {
const initialExpandedState: Record<string, boolean> = {};
menuItems.forEach(item => {
if (item.children) {
initialExpandedState[item.id] = true;
}
});
setExpandedMenus(initialExpandedState);
}, [menuItems]);
const toggleMenu = (id: string, e: React.MouseEvent) => {
// 我们只防止事件冒泡,不阻止默认行为
e.stopPropagation();
// console.log('父菜单展开/折叠:', id);
setExpandedMenus(prev => ({
...prev,
[id]: !prev[id]
}));
};
const isActive = (path: string) => {
return location.pathname === path || location.pathname.startsWith(`${path}/`);
};
// 处理侧边栏切换事件
const handleToggleSidebar = (e: React.MouseEvent) => {
// console.log('侧边栏折叠/展开');
// 只防止事件冒泡,不阻止默认行为
e.stopPropagation();
onToggle();
};
// 处理子菜单项点击事件
const handleSubMenuClick = (child: MenuItem, e: React.MouseEvent) => {
// 只需要阻止冒泡,不阻止默认行为
e.stopPropagation();
// console.log('子菜单点击:', child.title, '路径:', child.path);
};
// const isPort51707 = typeof window !== 'undefined' && window.location.port === '51707'
// 处理菜单项:清理子菜单结构
const processedMenuItems: MenuItem[] = menuItems.filter(item =>{
// console.log('菜单项:', item.title, 'Icon:', item.icon)
// 🔑 优先检查:如果处于系统设置模式,只显示 /settings 及其子路由
if (isSettingsMode) {
return item.path === '/settings' || item.path?.startsWith('/settings/');
}
// 🔑 优先检查:如果处于交叉评查模式,只显示 /cross-checking 及其子路由
if (isCrossCheckingMode) {
return item.path === '/cross-checking' || item.path?.startsWith('/cross-checking/');
}
// 🔑 重要:非系统设置模式下,隐藏所有 /settings 相关菜单
if (item.path === '/settings' || item.path?.startsWith('/settings/')) {
return false;
}
// 🔑 重要:非交叉评查模式下,隐藏所有 /cross-checking 相关菜单
if (item.path === '/cross-checking' || item.path?.startsWith('/cross-checking/')) {
return false;
}
// 如果是省局访问
// if(isPort51707){
// if (selectedModuleName === '智慧法务大模型'){
// return item.path && item.path.startsWith('/chat-with-llm')
// }
// return item.path && item.path.startsWith('/cross-checking')
// }
// 🔑 如果选择了"智慧法务大模型",显示 /chat-with-llm 和 /dataset-manager 相关菜单
if (selectedModuleName === '智慧法务大模型') {
return item.path === '/chat-with-llm' || item.path?.startsWith('/chat-with-llm/')
}
// 🔑 如果选择了包含"合同"的模块
if (selectedModuleName.includes('合同')) {
// 排除智慧法务大模型专属菜单
if (item.path === '/chat-with-llm' || item.path?.startsWith('/chat-with-llm/')) {
return false;
}
// 保留其他所有菜单(包括 /contract-template
return true;
}
// 🔑 其他模块:排除特殊菜单
// 排除智慧法务大模型专属菜单
if (item.path === '/chat-with-llm' || item.path?.startsWith('/chat-with-llm/')) {
return false;
}
// 排除合同专属菜单
if (item.path === '/contract-template' || item.path?.startsWith('/contract-template/')) {
return false;
}
// 保留其他菜单
return true;
}).map((item): MenuItem => {
// 处理子菜单:过滤隐藏的子菜单
if (item.children && item.children.length > 0) {
// 过滤掉 hideBreadcrumb=true 的子菜单(这些通常是隐藏菜单)
const visibleChildren = item.children.filter(child => !child.hideBreadcrumb);
// 如果过滤后没有可见的子菜单,返回不带子菜单的父级(变成可点击的单级菜单)
if (visibleChildren.length === 0) {
const { children, ...itemWithoutChildren } = item;
return itemWithoutChildren as MenuItem;
}
// 如果还有可见的子菜单,返回带过滤后子菜单的项
return { ...item, children: visibleChildren };
}
// 处理空 children 数组或 undefined 的情况
if (item.children !== undefined) {
// 如果 children 存在但为空数组,移除它(让父级变成可点击的单级菜单)
const { children, ...itemWithoutChildren } = item;
return itemWithoutChildren as MenuItem;
}
// 没有子菜单的项直接返回
return item;
});
return (
<>
{/* 移动端遮罩层 */}
{isMobile && !collapsed && (
<div
className="sidebar-overlay"
onClick={onToggle}
role="button"
tabIndex={0}
aria-label="关闭侧边栏"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onToggle();
}
}}
/>
)}
<div className={`sidebar ${collapsed ? 'collapsed' : ''} ${isMobile ? 'sidebar-mobile' : ''} flex flex-col`}>
<div className="py-6 px-4 border-b border-gray-100 flex justify-between items-center">
<div className="flex items-center"
onClick={() => {
navigate('/');
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
navigate('/');
}
}}
>
<img src="/logo.svg" alt="智慧法务" className="w-12 h-12 mr-2" />
{!collapsed && <h2 className="text-lg font-medium"></h2>}
</div>
<button
className="sidebar-toggle"
onClick={handleToggleSidebar}
aria-label={collapsed ? "展开侧边栏" : "折叠侧边栏"}
type="button"
>
<i className={`${collapsed ? 'ri-menu-unfold-line' : 'ri-menu-fold-line'}`}></i>
</button>
</div>
{/* 显示入口模块的名称 */}
{selectedModuleName && (
<div className="py-3 px-4 border-b border-gray-100">
<div className={`flex items-center ${collapsed ? 'justify-center' : ''}`}>
{selectedModulePicPath && (
<img
src={selectedModuleName === '智慧法务大模型' || selectedModuleName === '交叉评查' ? selectedModulePicPath : `${DOCUMENT_URL}${selectedModulePicPath}`}
alt={selectedModuleName}
className={`${collapsed ? 'w-8 h-8' : 'w-6 h-6 mr-3'}`}
/>
)}
{!collapsed && (
<span className="text-base font-medium text-green-700">{selectedModuleName}</span>
)}
</div>
</div>
)}
<div className="py-4 px-[10px] flex-1 overflow-y-auto sidebar-scroll-area">
{isLoadingRoutes ? (
// 加载中状态显示,保留菜单布局结构
<div className="py-2">
{Array(5).fill(0).map((_, index) => (
<div key={index} className="sidebar-menu-item-skeleton mb-2">
<div className="flex items-center">
<div className="bg-gray-200 rounded-md h-5 w-5 mr-3 animate-pulse"></div>
{!collapsed && <div className="bg-gray-200 rounded-md h-4 w-24 animate-pulse"></div>}
</div>
</div>
))}
</div>
) : (
// 数据加载完成后显示菜单
processedMenuItems.map((item) => (
<div key={item.id} className={`${collapsed ? 'px-0' : ''}`}>
{!item.children ? (
<Link
to={item.path}
className={`sidebar-menu-item ${isActive(item.path) ? 'active' : ''} flex items-center ${collapsed ? 'justify-center' : ''}`}
onClick={(e) => {
// 只阻止冒泡,不阻止默认行为
e.stopPropagation();
// console.log('单级菜单点击:', item.title, '路径:', item.path);
}}
>
<i className={`${item.icon} ${collapsed ? 'text-xl' : 'mr-3'}`}></i>
{!collapsed && <span>{item.title}</span>}
</Link>
) : (
<>
<div
className={`sidebar-menu-item flex items-center ${collapsed ? 'justify-center' : 'justify-between'} cursor-pointer z-10`}
onClick={(e) => {
// console.log('%c父菜单点击 ===> ', 'background: #722ed1; color: white; padding: 2px 4px; border-radius: 2px;', item.title);
toggleMenu(item.id, e);
}}
role="button"
tabIndex={0}
aria-expanded={expandedMenus[item.id] || false}
aria-controls={`submenu-${item.id}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleMenu(item.id, (e as unknown) as React.MouseEvent);
}
}}
>
<div className="flex items-center">
<i className={`${item.icon} ${collapsed ? 'text-xl' : 'mr-3'}`}></i>
{!collapsed && <span>{item.title}</span>}
</div>
{!collapsed && (
<i className={`ri-arrow-${expandedMenus[item.id] ? 'down' : 'right'}-s-line`}></i>
)}
</div>
{(expandedMenus[item.id] || collapsed) && (
<div
className={`submenu-container ${collapsed ? 'border-l-0 pl-0' : 'border-l border-gray-100 ml-4 pl-3'} z-20`}
id={`submenu-${item.id}`}
>
{item.children.map((child) => (
<Link
key={child.id}
to={child.path}
className={`sidebar-menu-item ${isActive(child.path) ? 'active' : ''} flex items-center ${collapsed ? 'justify-center' : ''}`}
onClick={(e) => handleSubMenuClick(child, e)}
>
<i className={`${child.icon} ${collapsed ? 'text-xl' : 'mr-3'}`}></i>
{!collapsed && <span>{child.title}</span>}
</Link>
))}
</div>
)}
</>
)}
</div>
))
)}
</div>
{/* 操作手册下载按钮 */}
<div className="mt-auto px-4 py-1 border-t border-gray-100">
<a
href="/智慧法务平台操作手册.pdf"
download="智慧法务平台操作手册.pdf"
className={`flex items-center ${collapsed ? 'justify-center' : ''} text-gray-600 hover:text-green-700 transition-colors duration-200`}
title="下载操作手册"
>
<i className={`ri-question-line ${collapsed ? 'text-base' : 'text-base mr-3'}`}></i>
{!collapsed && <span className="text-base"></span>}
</a>
</div>
</div>
</>
);
}