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

645 lines
24 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, CROSS_CHECKING_ONLY_PORT, CROSS_CHECKING_ONLY_MODE } from '~/config/api-config';
import { normalizeRoutePathForPermission } from '~/utils/route-alias';
interface SidebarProps {
onToggle: () => void;
collapsed: boolean;
userRole: UserRole;
frontendJWT?: string;
menuItems?: MenuItem[];
}
export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '', menuItems: initialMenuItems = [] }: SidebarProps) {
const location = useLocation();
const [expandedMenus, setExpandedMenus] = useState<Record<string, boolean>>({});
const [menuItems, setMenuItems] = useState<MenuItem[]>(initialMenuItems); // 动态菜单项
const [isLoadingRoutes, setIsLoadingRoutes] = useState<boolean>(initialMenuItems.length === 0); // 路由加载状态
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(() => {
if (initialMenuItems.length > 0) {
setMenuItems(initialMenuItems);
setIsLoadingRoutes(false);
return;
}
const fetchUserRoutes = async () => {
setIsLoadingRoutes(true);
try {
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;
}
const result = await getUserRoutesByRole(userRole, jwt);
if (result.success && result.data) {
setMenuItems(result.data);
} else {
console.error('❌ [Sidebar] 获取用户路由权限失败:', result.error);
if (result.shouldRedirectToHome) {
navigate('/');
return;
}
setMenuItems([]);
}
} catch (error) {
console.error('❌ [Sidebar] 获取用户路由权限时发生错误:', error);
navigate('/');
return;
} finally {
setIsLoadingRoutes(false);
}
};
fetchUserRoutes();
}, [userRole, frontendJWT, navigate, initialMenuItems]);
// 🔑 检查是否处于系统设置模式或交叉评查模式
const [isSettingsMode, setIsSettingsMode] = useState<boolean>(false);
const [isCrossCheckingMode, setIsCrossCheckingMode] = useState<boolean>(false);
// 🔒 检测当前端口,用于控制交叉评查入口的显示
const [currentPort, setCurrentPort] = useState<string>('');
const isPathInSection = (prefix: string) =>
location.pathname === prefix || location.pathname.startsWith(`${prefix}/`);
const isSettingsPath = isPathInSection('/settings')
|| isPathInSection('/entry-modules')
|| isPathInSection('/role-permissions')
|| isPathInSection('/document-types')
|| isPathInSection('/rule-groups');
const isCrossCheckingPath = isPathInSection('/cross-checking');
const isChatPath = isPathInSection('/chat-with-llm');
const isContractTemplatePath = isPathInSection('/contract-template') || isPathInSection('/contract-draft');
const shouldForceDocumentNavigation = isPathInSection('/reviewsTest');
const navigateWithFallback = (path: string) => {
if (shouldForceDocumentNavigation && typeof window !== 'undefined') {
window.location.assign(path);
return;
}
navigate(path);
};
useEffect(() => {
if (typeof window !== 'undefined') {
setCurrentPort(window.location.port || '');
}
}, []);
// 从 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');
const hasStaleSpecialModule = (moduleName === '智慧法务助手' || moduleName === '交叉评查')
&& !isChatPath
&& !isCrossCheckingPath
&& !isSettingsPath
&& location.pathname !== '/';
if (hasStaleSpecialModule) {
sessionStorage.removeItem('selectedModuleName');
sessionStorage.removeItem('selectedModulePicPath');
setSelectedModuleName('');
setSelectedModulePicPath('');
console.log('🧹 [Sidebar] 已清理残留的特殊入口模块状态:', moduleName);
} else {
setSelectedModuleName(moduleName || '');
setSelectedModulePicPath(modulePicPath || '');
if (moduleName) {
console.log('📌 [Sidebar] 当前选中模块:', moduleName);
}
if (modulePicPath) {
console.log('🖼️ [Sidebar] 模块图片路径:', modulePicPath);
}
}
// 当前路由优先,避免历史 sessionStorage 状态把普通业务页侧边栏劫持成设置页/交叉评查页
if (isSettingsPath) {
setIsSettingsMode(true);
setIsCrossCheckingMode(false);
console.log('⚙️ [Sidebar] 根据当前路由进入系统设置模式');
} else if (isCrossCheckingPath) {
setIsCrossCheckingMode(true);
setIsSettingsMode(false);
console.log('🔀 [Sidebar] 根据当前路由进入交叉评查模式');
} else if (settingsMode === 'true' && location.pathname === '/') {
setIsSettingsMode(true);
setIsCrossCheckingMode(false);
console.log('⚙️ [Sidebar] 首页保留系统设置模式');
} else if (crossCheckingMode === 'true' && location.pathname === '/') {
setIsCrossCheckingMode(true);
setIsSettingsMode(false);
console.log('🔀 [Sidebar] 首页保留交叉评查模式');
} else {
setIsSettingsMode(false);
setIsCrossCheckingMode(false);
// 进入普通业务页时同步清理模式标志,避免后续刷新再次读到脏状态
sessionStorage.removeItem('settingsMode');
sessionStorage.removeItem('crossCheckingMode');
}
// 兼容用户直接刷新/直达子页时没有 sessionStorage 场景
if (!moduleName) {
if (isChatPath) {
setSelectedModuleName('智慧法务助手');
setSelectedModulePicPath('/images/icon_assistant.png');
} else if (isCrossCheckingPath) {
setSelectedModuleName('交叉评查');
setSelectedModulePicPath('/images/icon_cross@2x.png');
} else if (isContractTemplatePath) {
setSelectedModuleName('合同管理');
}
}
} catch (error) {
console.error('❌ [Sidebar] 读取 sessionStorage 失败:', error);
}
}
}, [isChatPath, isContractTemplatePath, isCrossCheckingPath, 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) => {
const target = new URL(path, 'http://sidebar.local');
if (target.search) {
const currentParams = new URLSearchParams(location.search);
return location.pathname === target.pathname && Array.from(target.searchParams.entries()).every(
([key, value]) => currentParams.get(key) === value
);
}
return location.pathname === target.pathname || location.pathname.startsWith(`${target.pathname}/`);
};
// 处理侧边栏切换事件
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 isRuleManagementMenu = (item: MenuItem) =>
item.id === 'rule-management' ||
item.path === '/rules' ||
item.title === '规则管理' ||
!!item.children?.some(child => child.id === 'rules-list' || child.path === '/rules/list');
const specialModeModuleNames = new Set(['智慧法务助手', '交叉评查']);
const shouldHideModuleBanner = isSettingsPath;
const effectiveSelectedModuleName = (() => {
if (shouldHideModuleBanner) {
return '';
}
if (isChatPath) {
return '智慧法务助手';
}
if (isCrossCheckingPath) {
return '交叉评查';
}
if (isContractTemplatePath && !selectedModuleName) {
return '合同管理';
}
if (!isChatPath && !isCrossCheckingPath && specialModeModuleNames.has(selectedModuleName)) {
return '';
}
return selectedModuleName;
})();
const effectiveSelectedModulePicPath = (() => {
if (shouldHideModuleBanner) {
return '';
}
if (isChatPath) {
return '/images/icon_assistant.png';
}
if (isCrossCheckingPath) {
return '/images/icon_cross@2x.png';
}
if (!isChatPath && !isCrossCheckingPath && specialModeModuleNames.has(selectedModuleName)) {
return '';
}
return selectedModulePicPath;
})();
const isCaseFileModule = effectiveSelectedModuleName.includes('案卷') || effectiveSelectedModuleName.includes('卷宗');
const isAssistantModule = effectiveSelectedModuleName === '智慧法务助手' || isChatPath;
const isContractModule = effectiveSelectedModuleName.includes('合同') || isContractTemplatePath;
const effectiveSettingsMode = isSettingsMode || isSettingsPath;
const effectiveCrossCheckingMode = isCrossCheckingMode || isCrossCheckingPath;
const buildRulesTestListPath = (mainType?: string) => {
const params = new URLSearchParams();
if (isCaseFileModule) {
params.set('documentType', '案卷');
if (mainType) params.set('mainType', mainType);
} else if (isContractModule) {
params.set('documentType', '合同');
if (mainType) {
params.set('mainType', mainType);
}
} else if (effectiveSelectedModuleName.includes('公文')) {
params.set('documentType', '内部公文');
if (mainType) {
params.set('mainType', mainType);
}
} else if (effectiveSelectedModuleName) {
params.set('documentType', effectiveSelectedModuleName);
params.set('mainType', effectiveSelectedModuleName);
}
const query = params.toString();
return query ? `/rulesTest/list?${query}` : '/rulesTest/list';
};
const normalizeRuleManagementMenu = (item: MenuItem): MenuItem => {
if (!isRuleManagementMenu(item)) {
return item;
}
if (isCaseFileModule) {
return {
...item,
path: buildRulesTestListPath(),
children: undefined
};
}
return {
...item,
children: item.children?.map(child => (
child.id === 'rules-list' || child.path === '/rules' || child.path === '/rules/list'
? { ...child, path: buildRulesTestListPath() }
: child
))
};
};
// const isPort51707 = typeof window !== 'undefined' && window.location.port === '51707'
const dedupeMenuItems = (items: MenuItem[]): MenuItem[] => {
const descendantKeys = new Set<string>();
const buildMenuIdentity = (item: MenuItem) => {
const normalizedPath = normalizeRoutePathForPermission(item.path || '');
return `${normalizedPath}::${item.title}`;
};
const collectDescendantPaths = (children?: MenuItem[]) => {
if (!children || children.length === 0) {
return;
}
children.forEach((child) => {
descendantKeys.add(buildMenuIdentity(child));
collectDescendantPaths(child.children);
});
};
items.forEach((item) => collectDescendantPaths(item.children));
return items.filter((item) => !descendantKeys.has(buildMenuIdentity(item)));
};
// 处理菜单项:清理子菜单结构
const processedMenuItems: MenuItem[] = dedupeMenuItems(menuItems.filter(item =>{
// console.log('菜单项:', item.title, 'Icon:', item.icon)
// 🔑 优先检查:如果处于系统设置模式,只显示 /settings 及其子路由
if (effectiveSettingsMode) {
return item.path === '/settings' || item.path?.startsWith('/settings/');
}
// 🔑 优先检查:如果处于交叉评查模式,只显示 /cross-checking 及其子路由
if (effectiveCrossCheckingMode) {
return item.path === '/cross-checking' || item.path?.startsWith('/cross-checking/');
}
// 🔑 重要:非系统设置模式下,隐藏所有 /settings 相关菜单
if (item.path === '/settings' || item.path?.startsWith('/settings/')) {
return false;
}
// 🔒 交叉评查访问控制:
// - CROSS_CHECKING_ONLY_MODE=false 时,所有端口都可访问(根据后端权限)
// - CROSS_CHECKING_ONLY_MODE=true 时,只有 51707 端口可访问
if (CROSS_CHECKING_ONLY_MODE && currentPort && currentPort !== CROSS_CHECKING_ONLY_PORT) {
if (item.path === '/cross-checking' || item.path?.startsWith('/cross-checking/')) {
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 (isAssistantModule) {
return item.path === '/chat-with-llm' || item.path?.startsWith('/chat-with-llm/')
}
// 🔑 如果选择了包含"合同"的模块
if (isContractModule) {
// 排除智慧法务助手专属菜单
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(normalizeRuleManagementMenu).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={() => {
navigateWithFallback('/');
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
navigateWithFallback('/');
}
}}
>
<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>
{/* 显示入口模块的名称 */}
{effectiveSelectedModuleName && (
<div className="py-3 px-4 border-b border-gray-100">
<div className={`flex items-center ${collapsed ? 'justify-center' : ''}`}>
{effectiveSelectedModulePicPath && (
<img
src={effectiveSelectedModuleName === '智慧法务助手' || effectiveSelectedModuleName === '交叉评查' ? effectiveSelectedModulePicPath : `${DOCUMENT_URL}${effectiveSelectedModulePicPath}`}
alt={effectiveSelectedModuleName}
className={`${collapsed ? 'w-8 h-8' : 'w-6 h-6 mr-3'}`}
/>
)}
{!collapsed && (
<span className="text-base font-medium text-green-700">{effectiveSelectedModuleName}</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}
reloadDocument={shouldForceDocumentNavigation}
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}
reloadDocument={shouldForceDocumentNavigation}
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>
</>
);
}