Files
leaudit-platform-frontend/app/components/layout/Sidebar.tsx
T

473 lines
18 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';
interface SidebarProps {
onToggle: () => void;
collapsed: boolean;
userRole: UserRole;
frontendJWT?: string;
selectedApp?: string; // 添加所选应用模块参数
}
// 已移除 APP_MENU_MAP:路由的显示/隐藏由后端 is_hidden 字段控制
// 只保留特殊规则:
// - /chat-with-llm 只在 model 模块中显示
// - /contract-template 只在 contract 模块中显示
// 应用模块名称映射
const APP_NAME_MAP: Record<string, string> = {
'contract': '合同管理',
'record': '案卷智能评查',
'model': '智慧法务大模型'
};
// 应用模块图标映射
const APP_ICON_MAP: Record<string, string> = {
'contract': '/images/icon_hetong.png',
'record': '/images/icon_anjuan.png',
'model': '/images/icon_assistant.png'
};
export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '', selectedApp = '' }: SidebarProps) {
const location = useLocation();
const [expandedMenus, setExpandedMenus] = useState<Record<string, boolean>>({});
const [currentApp, setCurrentApp] = useState<string>(''); // 初始设置为空字符串而不是selectedApp
const [isLoading, setIsLoading] = useState<boolean>(true); // 添加加载状态
const [menuItems, setMenuItems] = useState<MenuItem[]>([]); // 动态菜单项
const [isLoadingRoutes, setIsLoadingRoutes] = useState<boolean>(true); // 路由加载状态
const [isMobile, setIsMobile] = useState<boolean>(false); // 移动端检测
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(() => {
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 未找到');
setMenuItems([]);
setIsLoadingRoutes(false);
return;
}
console.log('🔍 [Sidebar] 当前用户角色:', userRole);
const roleKey = mapUserRoleToRoleKey(userRole);
const result = await getUserRoutesByRole(roleKey, 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]);
// 组件挂载后从 sessionStorage 读取初始 reviewType
useEffect(() => {
let mounted = true;
setIsLoading(true); // 设置加载状态
try {
const reviewType = sessionStorage.getItem('reviewType');
// console.log('初始 reviewType:', reviewType);
if (reviewType && mounted) {
setCurrentApp(reviewType);
} else if (selectedApp && mounted) {
// 如果没有reviewType,但有selectedApp,使用selectedApp
setCurrentApp(selectedApp);
}
} catch (error) {
console.error('读取 reviewType 失败:', error);
} finally {
// 使用setTimeout确保状态在DOM更新后再变化,避免闪烁
setTimeout(() => {
if (mounted) {
setIsLoading(false); // 数据加载完成
}
}, 0);
}
return () => {
mounted = false;
};
}, [selectedApp]);
// 从 sessionStorage 获取 reviewType 并设置当前应用模块
useEffect(() => {
// 监听 sessionStorage 变化(主要用于多标签页情况)
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'reviewType' && e.newValue) {
setCurrentApp(e.newValue);
}
};
// 添加事件监听器
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, []);
// 监听路由变化,重新检查 reviewType
useEffect(() => {
let mounted = true;
try {
const reviewType = sessionStorage.getItem('reviewType');
// 只有当reviewType变化时才设置加载状态和更新currentApp
if (reviewType && reviewType !== currentApp && mounted) {
setIsLoading(true); // 路由变化时设置加载状态
setCurrentApp(reviewType);
// 使用setTimeout确保状态在DOM更新后再变化,避免闪烁
setTimeout(() => {
if (mounted) {
setIsLoading(false);
}
}, 0);
}
} catch (error) {
console.error('路由变化时读取 reviewType 失败:', error);
if (mounted) {
setIsLoading(false);
}
}
return () => {
mounted = false;
};
}, [location.pathname, currentApp]);
// 监听 selectedApp 属性变化
useEffect(() => {
if (selectedApp && selectedApp !== currentApp) {
setIsLoading(true); // 设置加载状态
setCurrentApp(selectedApp);
// 使用setTimeout确保状态在DOM更新后再变化,避免闪烁
setTimeout(() => {
setIsLoading(false); // 数据加载完成
}, 0);
}
}, [selectedApp, currentApp]);
// 初始化展开状态,默认全部展开
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);
};
// 检查是否通过51707端口访问(省局)
const isPort51707 = typeof window !== 'undefined' && window.location.port === '51707';
// 根据当前应用模式过滤菜单项
const filteredMenuItems = menuItems
.filter(item => {
// 如果是51707端口,只显示交叉评查相关菜单
if (isPort51707) {
// 如果当前应用是智慧法务大模型,只显示AI对话菜单
if (currentApp === 'model') {
return item.path && item.path.startsWith('/chat-with-llm');
} else {
return item.path && item.path.startsWith('/cross-checking');
}
}
// 特殊规则1/chat-with-llm 只在 model 模块中显示
if (item.path === '/chat-with-llm' || item.path?.startsWith('/chat-with-llm/')) {
return currentApp === 'model';
}
// 特殊规则2/contract-template 只在 contract 模块中显示
if (item.path === '/contract-template' || item.path?.startsWith('/contract-template/')) {
return currentApp === 'contract';
}
// 其他路由:后端已通过 is_hidden 控制显示/隐藏,这里全部保留
return true;
})
.map(item => {
// 处理子菜单:过滤隐藏的子菜单
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;
}
// 如果还有可见的子菜单,返回带过滤后子菜单的项
return { ...item, children: visibleChildren };
}
// 处理空 children 数组或 undefined 的情况
if (item.children !== undefined) {
// 如果 children 存在但为空数组,移除它(让父级变成可点击的单级菜单)
const { children, ...itemWithoutChildren } = item;
return itemWithoutChildren;
}
// 没有子菜单的项直接返回
return item;
});
// filteredMenuItems = filteredMenuItems.map(item => {
// if(item.children && item.children.length > 0){
// const children = item.children.filter(child => {
// const isUploadByPath = child.path === '/files/upload' || child.path?.startsWith('/files/upload')
// const isUploadByTitle = child.title === '文件上传'
// return !(isUploadByPath || isUploadByTitle)
// })
// return { ...item, children}
// }
// 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>
{!collapsed && (
<div className="px-4 py-3 border-b border-gray-100">
<div className="flex items-center text-green-700">
{isLoading ? (
// 加载中状态,只显示加载图标,保留布局
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-700 mr-2"></div>
<span className="font-medium text-gray-500">...</span>
</div>
) : (
<>
<img src={APP_ICON_MAP[currentApp] || ''} alt={APP_NAME_MAP[currentApp] || ''} className="w-6 h-6 mr-2" />
<span className="font-medium">{APP_NAME_MAP[currentApp] || ''}</span>
</>
)}
</div>
</div>
)}
<div className="py-4 px-[10px] flex-1 overflow-y-auto sidebar-scroll-area">
{isLoading || 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>
) : (
// 数据加载完成后显示菜单
filteredMenuItems.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>
</>
);
}