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

311 lines
12 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { Sidebar } from './Sidebar';
// import { Header } from './Header';
import { Breadcrumb } from './Breadcrumb';
import { useMatches, useLocation } from '@remix-run/react';
import type { UserRole } from '~/root';
interface LayoutProps {
children: React.ReactNode;
userRole?: UserRole;
frontendJWT?: string;
isMobile?: boolean; // 是否为移动端设备(服务端通过 User-Agent 检测)
}
// 添加一个接口表示路由handle可能包含的属性
interface RouteHandle {
hideBreadcrumb?: boolean;
collapseSidebar?: boolean;
noPadding?: boolean;
[key: string]: unknown;
}
interface Match {
handle?: RouteHandle;
pathname: string;
data: unknown;
}
type RulesTestDetailData = {
pack?: {
documentType?: string;
mainType?: string;
fields?: unknown[];
subDocuments?: unknown[];
visualElements?: unknown[];
};
};
type RulesTestListData = {
filters?: {
documentType?: string;
mainType?: string;
subtype?: string;
ruleGroup?: string;
keyword?: string;
};
options?: {
subtypes?: string[];
ruleGroups?: string[];
};
};
export function Layout({ children, userRole = 'developer' as UserRole, frontendJWT = '', isMobile = false }: LayoutProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [effectiveUserRole, setEffectiveUserRole] = useState<UserRole>(userRole);
const [effectiveFrontendJWT, setEffectiveFrontendJWT] = useState<string>(frontendJWT);
const matches = useMatches() as Match[];
const location = useLocation();
// 检查当前路径是否应该隐藏侧边栏
const noLayoutPaths = ['/login', '/'];
// 移动端设备强制隐藏侧边栏
const shouldHideSidebar = isMobile || noLayoutPaths.includes(location.pathname);
// 检查当前路由是否应该隐藏默认面包屑
// 移动端设备强制隐藏面包屑(避免显示首页链接)
const shouldHideBreadcrumb = isMobile || shouldHideSidebar || matches.some(match =>
match.handle && match.handle.hideBreadcrumb === true
);
// 检查当前路由是否要求无 padding
const shouldNoPadding = matches.some(match =>
match.handle && match.handle.noPadding === true
);
// 从 localStorage 读取用户信息和 JWT 作为备用方案
useEffect(() => {
if (typeof window === 'undefined') return;
try {
// 如果服务端没有传递 userRole,从 localStorage 读取
if (!userRole || userRole === '') {
const storedUserInfoStr = localStorage.getItem('user_info');
if (storedUserInfoStr) {
const storedUserInfo = JSON.parse(storedUserInfoStr);
const storedUserRole = storedUserInfo.user_role || 'common';
console.log('📖 [Layout] 从 localStorage 读取用户角色:', storedUserRole);
setEffectiveUserRole(storedUserRole as UserRole);
}
} else {
setEffectiveUserRole(userRole);
}
// 如果服务端没有传递 frontendJWT,从 localStorage 读取
if (!frontendJWT || frontendJWT === '') {
const storedToken = localStorage.getItem('access_token');
if (storedToken) {
console.log('📖 [Layout] 从 localStorage 读取 JWT token');
setEffectiveFrontendJWT(storedToken);
}
} else {
setEffectiveFrontendJWT(frontendJWT);
}
} catch (error) {
console.error('❌ [Layout] 读取 localStorage 失败:', error);
}
}, [userRole, frontendJWT]);
// 从localStorage中获取侧边栏状态
useEffect(() => {
// 检查是否为移动端
const isMobile = window.innerWidth <= 768;
// 检查当前路由是否要求收缩侧边栏
const shouldCollapse = matches.some(match =>
match.handle && match.handle.collapseSidebar === true
);
if (isMobile) {
setSidebarCollapsed(true);
} else if (shouldCollapse) {
setSidebarCollapsed(true);
} else {
// 从localStorage获取侧边栏状态
const savedState = localStorage.getItem('sidebarCollapsed');
if (savedState) {
setSidebarCollapsed(savedState === 'true');
}
}
}, [location.pathname]);
const toggleSidebar = () => {
const newState = !sidebarCollapsed;
setSidebarCollapsed(newState);
localStorage.setItem('sidebarCollapsed', String(newState));
};
// 切换应用模块
// const changeAppModule = (appId: AppModule) => {
// setSelectedApp(appId);
// localStorage.setItem('selectedApp', appId);
// };
// 如果是无布局页面,只渲染内容
if (shouldHideSidebar) {
return <>{children}</>;
}
const isRulesTestList = location.pathname.startsWith('/rulesTest/list');
const isRulesTestDetail = location.pathname.startsWith('/rulesTest/detail');
const isRulesTestTopbarPage = isRulesTestList || isRulesTestDetail;
const rulesTestListData = matches.find(match => match.pathname.startsWith('/rulesTest/list'))?.data as RulesTestListData | undefined;
const rulesTestDetailData = matches.find(match => match.pathname.startsWith('/rulesTest/detail'))?.data as RulesTestDetailData | undefined;
const listFilters = rulesTestListData?.filters || {};
const listOptions = rulesTestListData?.options || {};
const detailPack = rulesTestDetailData?.pack;
const isContractDetail = !!detailPack?.documentType?.includes('合同');
const isCaseFileDetail = !!detailPack?.documentType?.includes('案卷');
const showFieldNav = isContractDetail && (detailPack?.fields?.length || 0) > 0;
const showSubDocumentNav = isCaseFileDetail && (detailPack?.subDocuments?.length || 0) > 0;
const showVisualNav = (detailPack?.visualElements?.length || 0) > 0;
const rulesListHref = detailPack?.documentType
? `/rulesTest/list?documentType=${encodeURIComponent(detailPack.documentType)}${detailPack.mainType ? `&mainType=${encodeURIComponent(detailPack.mainType)}` : ''}`
: '/rulesTest/list';
const listScopeText = [
listFilters.documentType,
listFilters.mainType && listFilters.mainType !== listFilters.documentType ? listFilters.mainType : ''
].filter(Boolean).join(' / ');
const submitTopbarFilter = (event: React.ChangeEvent<HTMLSelectElement>) => {
event.currentTarget.form?.requestSubmit();
};
return (
<div className="layout-container">
{/* 侧边栏始终保留,不再使用条件渲染 */}
<Sidebar
collapsed={sidebarCollapsed}
onToggle={toggleSidebar}
userRole={effectiveUserRole}
frontendJWT={effectiveFrontendJWT}
/>
{/* 规则列表页顶部栏 */}
{isRulesTestList && (
<div className={`page-topbar rules-list-topbar ${sidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
<div className="topbar-content">
<div className="topbar-left">
<span className="topbar-icon" aria-hidden="true">
<i className="ri-list-settings-line"></i>
</span>
<div className="topbar-heading">
<h2 className="topbar-title"></h2>
<span className="topbar-breadcrumb">
<span></span>
{listScopeText && (
<>
<span className="separator">/</span>
<span>{listScopeText}</span>
</>
)}
</span>
</div>
</div>
<div className="topbar-right">
<a className="topbar-action secondary" href="/rules/list">
<i className="ri-history-line"></i>
<span></span>
</a>
</div>
</div>
<form method="get" action="/rulesTest/list" className="topbar-filter-strip">
<input type="hidden" name="documentType" defaultValue={listFilters.documentType || ''} />
{listFilters.mainType && <input type="hidden" name="mainType" defaultValue={listFilters.mainType} />}
<label className="topbar-filter-field">
<span></span>
<select name="subtype" value={listFilters.subtype || ''} onChange={submitTopbarFilter}>
<option value=""></option>
{(listOptions.subtypes || []).map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
</label>
<label className="topbar-filter-field">
<span></span>
<select name="ruleGroup" value={listFilters.ruleGroup || ''} onChange={submitTopbarFilter}>
<option value=""></option>
{(listOptions.ruleGroups || []).map(group => (
<option key={group} value={group}>{group}</option>
))}
</select>
</label>
<label className="topbar-filter-field topbar-filter-field-search">
<span></span>
<input
key={listFilters.keyword || 'empty-keyword'}
name="keyword"
defaultValue={listFilters.keyword || ''}
placeholder="规则名称 / 编码 / 规则组"
/>
</label>
<button className="topbar-action" type="submit">
<i className="ri-search-line"></i>
<span></span>
</button>
</form>
</div>
)}
{/* 规则详情页顶部栏 */}
{isRulesTestDetail && (
<div className={`page-topbar ${sidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
<div className="topbar-content">
<div className="topbar-left">
<span className="topbar-icon" aria-hidden="true">
<i className="ri-file-settings-line"></i>
</span>
<div className="topbar-heading">
<h2 className="topbar-title"></h2>
<span className="topbar-breadcrumb">
<a href={rulesListHref}></a>
<span className="separator">/</span>
<span></span>
</span>
</div>
</div>
<div className="topbar-right">
<a className="topbar-action" href={rulesListHref}>
<i className="ri-arrow-left-line"></i>
<span></span>
</a>
<a className="topbar-action secondary" href="/rules/list">
<i className="ri-history-line"></i>
<span></span>
</a>
</div>
</div>
<div className="topbar-nav">
{showFieldNav && <a className="topbar-nav-link" href="#fields"><i className="ri-input-field"></i></a>}
{showSubDocumentNav && <a className="topbar-nav-link" href="#sub-documents"><i className="ri-file-list-3-line"></i></a>}
{showVisualNav && <a className="topbar-nav-link" href="#visual-elements"><i className="ri-stamp-line"></i></a>}
<a className="topbar-nav-link" href="#rules"><i className="ri-list-check-3"></i></a>
</div>
</div>
)}
<div className={`main-content ${sidebarCollapsed ? 'sidebar-collapsed' : ''} ${isRulesTestDetail ? 'rules-detail-main' : ''} ${isRulesTestList ? 'rules-list-main' : ''}`}>
{/* 应用模块选择器 */}
{/* <div className="app-module-selector py-2 px-4 border-b border-gray-100 flex items-center">
{APP_MODULES.map(app => (
<button
key={app.id}
onClick={() => changeAppModule(app.id as AppModule)}
className={`app-module-btn mr-4 py-2 px-4 rounded-md flex items-center ${
selectedApp === app.id ? 'bg-green-50 text-green-700 border border-green-200' : 'hover:bg-gray-50'
}`}
>
<i className={`${app.icon} mr-2`}></i>
<span>{app.name}</span>
</button>
))}
</div> */}
<div className={`content-container${shouldNoPadding ? ' !p-0' : ''}`}>
{!shouldHideBreadcrumb && !isRulesTestTopbarPage && <Breadcrumb />}
{children}
</div>
</div>
</div>
);
}