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

470 lines
16 KiB
TypeScript

import { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from '@remix-run/react';
import type { UserRole } from '~/root';
interface MenuItem {
id: string;
title: string;
path: string;
icon: string;
hideBreadcrumb?: boolean;
requiredRole?: UserRole;
children?: MenuItem[];
}
interface SidebarProps {
onToggle: () => void;
collapsed: boolean;
userRole: UserRole;
selectedApp?: string; // 添加所选应用模块参数
}
// 定义不同应用模块下显示的菜单项ID
const APP_MENU_MAP = {
'contract': ['home', 'contract-template', 'file-management', 'rule-management', 'system-settings'],
'record': ['home', 'file-management', 'rule-management', 'system-settings'],
'model': ['chat-with-llm']
};
// 应用模块名称映射
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, 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 navigate = useNavigate();
// 组件挂载后从 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]);
const menuItems: MenuItem[] = [
{
id: 'home',
title: '系统概览',
path: '/home',
icon: 'ri-home-line'
},
{
id: 'chat-with-llm',
title: 'AI对话',
path: '/chat-with-llm',
icon: 'ri-chat-smile-2-line'
},
{
id: 'file-management',
title: '文件管理',
path: '/files',
icon: 'ri-folder-line',
children: [
{
id: 'file-upload',
title: '文件上传',
path: '/files/upload',
icon: 'ri-upload-cloud-line'
},
{
id: 'documents',
title: '文档列表',
path: '/documents',
icon: 'ri-file-list-3-line'
}
]
},
{
id: 'rule-management',
title: '评查规则库',
path: '/rules',
icon: 'ri-book-3-line',
children: [
{
id: 'rule-groups',
title: '评查点分组',
path: '/rule-groups',
icon: 'ri-folder-open-line'
},
{
id: 'rules-list',
title: '评查点列表',
path: '/rules',
icon: 'ri-list-check-3'
},
{
id: 'rules-file',
title: '评查文件列表',
path: '/rules-files',
icon: 'ri-list-check-2'
},
{
id: 'cross-checking',
title: '交叉评查',
path: '/cross-checking',
icon: 'ri-color-filter-line'
},
// {
// id: 'rule-new',
// title: '新增评查点',
// path: '/rules-new',
// requiredRole: 'developer',
// icon: 'ri-add-circle-line'
// },
// {
// id: 'review-detail',
// title: '评查详情',
// path: '/reviews',
// icon: 'ri-file-chart-line'
// }
]
},
{
id: 'contract-template',
title: '合同模板',
path: '/contract-template',
icon: 'ri-file-search-line',
children: [
{
id: 'contract-search-ai',
title: '智能搜索',
path: '/contract-template/search',
icon: 'ri-search-line'
},
{
id: 'contract-list',
title: '合同列表',
path: '/contract-template/list',
icon: 'ri-folder-line'
}
]
},
{
id: 'system-settings',
title: '系统设置',
path: '/settings',
icon: 'ri-settings-4-line',
requiredRole: 'developer',
children: [
{
id: 'config-lists',
title: '配置列表',
path: '/config-lists',
icon: 'ri-list-check-3',
requiredRole: 'developer'
},
// {
// id: 'basic-settings',
// title: '基础设置',
// path: '/settings',
// icon: 'ri-equalizer-line'
// },
{
id: 'document-types',
title: '文档类型',
path: '/document-types',
icon: 'ri-file-list-line',
requiredRole: 'developer'
},
{
id: 'prompt-management',
title: '提示词管理',
path: '/prompts',
icon: 'ri-chat-1-line',
requiredRole: 'developer'
}
]
}
];
// 初始化展开状态,默认全部展开
useEffect(() => {
const initialExpandedState: Record<string, boolean> = {};
menuItems.forEach(item => {
if (item.children) {
initialExpandedState[item.id] = true;
}
});
setExpandedMenus(initialExpandedState);
}, []);
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);
};
// 获取当前应用模式下应显示的菜单ID列表
const visibleMenuIds = APP_MENU_MAP[currentApp as keyof typeof APP_MENU_MAP] || APP_MENU_MAP['contract'];
// const visibleMenuIds = APP_MENU_MAP[currentApp as keyof typeof APP_MENU_MAP]
// console.log('当前应用模式:', currentApp, '可见菜单ID:', visibleMenuIds);
// 根据用户角色和当前应用模式过滤菜单项
const filteredMenuItems = menuItems.filter(item => {
// 如果菜单项需要特定角色但用户没有
if (item.requiredRole && item.requiredRole !== userRole) {
return false;
}
// 检查当前菜单是否在所选应用模式中显示
if (!visibleMenuIds.includes(item.id)) {
return false;
}
return true;
});
return (
<div className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
<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]">
{isLoading ? (
// 加载中状态显示,保留菜单布局结构
<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
.filter(child => !child.requiredRole || child.requiredRole === userRole)
.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>
);
}