bfe39e45a9
5. 修改统一认证登录和管理员登录是通过接口形式进行,存储返回的accessToken。 6. 修改交叉评查的部分样式
491 lines
18 KiB
TypeScript
491 lines
18 KiB
TypeScript
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; // 添加所选应用模块参数
|
|
}
|
|
|
|
// 定义不同应用模块下显示的菜单路径(使用路由路径进行匹配)
|
|
const APP_MENU_MAP = {
|
|
'contract': [
|
|
'/home', // 系统概览
|
|
'/documents', // 文档管理
|
|
'/contract-template', // 合同模板
|
|
'/rules', // 评查规则库
|
|
'/cross-checking', // 交叉评查
|
|
// '/chat-with-llm', // AI法务助手
|
|
'/settings' // 系统设置
|
|
],
|
|
'record': [
|
|
'/home', // 系统概览
|
|
'/documents', // 文档管理
|
|
'/rules', // 评查规则库
|
|
'/cross-checking', // 交叉评查
|
|
// '/chat-with-llm', // AI法务助手
|
|
'/settings' // 系统设置
|
|
],
|
|
'model': [
|
|
'/chat-with-llm' // AI法务助手
|
|
]
|
|
};
|
|
|
|
// 应用模块名称映射
|
|
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);
|
|
};
|
|
|
|
// 获取当前应用模式下应显示的菜单路径列表
|
|
const visibleMenuPaths = APP_MENU_MAP[currentApp as keyof typeof APP_MENU_MAP] || APP_MENU_MAP['contract'];
|
|
// console.log('当前应用模式:', currentApp, '可见菜单路径:', visibleMenuPaths);
|
|
|
|
// 检查是否通过51707端口访问(省局)
|
|
// const isPort51708 = typeof window !== 'undefined' && window.location.port === '51708';
|
|
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');
|
|
}
|
|
}
|
|
|
|
// 检查当前菜单是否在所选应用模式中显示(使用路径匹配)
|
|
if (!visibleMenuPaths.includes(item.path)) {
|
|
return false;
|
|
}
|
|
|
|
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>
|
|
</>
|
|
);
|
|
} |