Files
leaudit-platform-frontend/app/routes/_index.tsx
T

500 lines
20 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 React, { useState, useEffect } from 'react';
import { useNavigate, Form, useLoaderData } from '@remix-run/react';
import { type MetaFunction, type ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import styles from "~/styles/pages/home.css?url";
import dayjs from 'dayjs';
import type { EntryModule } from '~/api/home/home';
import { getUserSession, logout } from "~/api/login/auth.server";
import { toastService } from '~/components/ui';
import { DOCUMENT_URL, CROSS_CHECKING_ONLY_MODE, CROSS_CHECKING_ONLY_PORT, getCurrentPort } from '~/config/api-config';
export const links = () => [
{ rel: "stylesheet", href: styles }
];
export const meta: MetaFunction = () => {
return [
{ title: "中国烟草AI合同及卷宗审核系统 - 首页" },
{ name: "description", content: "中国烟草AI合同及卷宗审核系统首页" },
];
};
// 处理登出请求
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "logout") {
return logout(request);
}
return null;
}
// 获取用户信息
export async function loader({ request }: LoaderFunctionArgs) {
// 🔒 认证检查已在 getUserSession() 中统一处理
// 如果未认证,会自动重定向到登录页,不会执行到这里
const { userRole, userInfo, frontendJWT } = await getUserSession(request);
// 🔑 获取用户地区并查询入口模块
let entryModules: EntryModule[] = [];
if (frontendJWT) {
const { getEntryModules } = await import('~/api/home/home');
entryModules = await getEntryModules(frontendJWT);
console.log(`📦 [Index Loader] 获取到 ${entryModules.length} 个入口模块`);
} else {
console.warn('⚠️ [Index Loader] 缺少 JWT,返回空模块列表');
}
// 🔑 检查用户是否有系统设置权限
let hasSettingsAccess = false;
let hasCrossCheckingAccess = false;
let hasChatLLMAccess = false;
let settingsChildren: { path: string; title: string }[] = [];
if (userRole && frontendJWT) {
const { getUserRoutesByRole } = await import('~/api/auth/user-routes');
const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true); // includeHidden=true
// console.log('🔍 [Index Loader] 顶级路由paths:', routesResult.data?.map(r => r.path));
if (routesResult.success && routesResult.data) {
// 查找 '/settings' 路由及其子路由
const settingsRoute = routesResult.data.find(route => route.path === '/settings');
if (settingsRoute) {
hasSettingsAccess = true;
// 提取子路由信息(仅 path 和 title
if (settingsRoute.children && settingsRoute.children.length > 0) {
settingsChildren = settingsRoute.children.map(child => ({
path: child.path,
title: child.title
}));
}
}
// 检查是否存在顶级路由 '/cross-checking'
// 🔒 交叉评查访问控制:
// - CROSS_CHECKING_ONLY_MODE=false 时,所有端口都可访问(根据后端权限)
// - CROSS_CHECKING_ONLY_MODE=true 时,只有 51707 端口可访问
const currentPort = getCurrentPort();
if (!CROSS_CHECKING_ONLY_MODE || currentPort === CROSS_CHECKING_ONLY_PORT) {
hasCrossCheckingAccess = routesResult.data.some(route => route.path === '/cross-checking');
} else {
hasCrossCheckingAccess = false; // CROSS_CHECKING_ONLY_MODE=true 且非51707端口不显示交叉评查入口
}
// 检查是否存在顶级路由 '/chat-with-llm'
hasChatLLMAccess = routesResult.data.some(route => route.path === '/chat-with-llm');
// console.log(`🔑 [Index Loader] 用户${hasSettingsAccess ? '有' : '没有'}系统设置权限`);
// console.log(`🔑 [Index Loader] 系统设置子路由数量: ${settingsChildren.length}`);
// console.log(`🔑 [Index Loader] 用户${hasCrossCheckingAccess ? '有' : '没有'}交叉评查权限`);
// console.log(`🔑 [Index Loader] 用户${hasChatLLMAccess ? '有' : '没有'}智慧法务助手权限`);
}
}
// 🔑 判断是否启用交叉评查专属模式
// 条件:CROSS_CHECKING_ONLY_MODE=true 且 当前端口为 51707
// 注意:currentPort 已在上面的权限检查中获取,这里复用(如果在 if 块外需要则重新获取)
const currentPortForMode = getCurrentPort();
const isCrossCheckingOnlyMode = CROSS_CHECKING_ONLY_MODE && currentPortForMode === CROSS_CHECKING_ONLY_PORT;
if (isCrossCheckingOnlyMode) {
console.log(`🔒 [Index Loader] 交叉评查专属模式已启用 (端口: ${currentPortForMode})`);
}
// 返回用户信息、入口模块和权限给客户端
return Response.json({
userRole,
userInfo,
entryModules,
hasSettingsAccess,
hasCrossCheckingAccess,
hasChatLLMAccess,
settingsChildren,
isCrossCheckingOnlyMode // 新增:交叉评查专属模式标志
});
}
export default function Index() {
const navigate = useNavigate();
const loaderData = useLoaderData<typeof loader>();
const [currentDateTime, setCurrentDateTime] = useState({
date: '',
time: ''
});
// 检查是否通过51707端口访问
// const [isPort51707, setIsPort51707] = useState(false);
// 用户信息:优先使用服务端返回的,否则从 localStorage 读取
const [userInfo, setUserInfo] = useState(loaderData.userInfo);
const [userRole, setUserRole] = useState(loaderData.userRole);
useEffect(() => {
if (typeof window !== 'undefined') {
// setIsPort51707(window.location.port === '51707');
// 如果服务端没有返回用户信息,从 localStorage 读取
if (!loaderData.userInfo || !loaderData.userRole) {
const storedUserInfoStr = localStorage.getItem('user_info');
if (storedUserInfoStr) {
try {
const storedUserInfo = JSON.parse(storedUserInfoStr);
console.log('📖 [Index] 从 localStorage 读取用户信息:', storedUserInfo);
setUserInfo(storedUserInfo);
setUserRole(storedUserInfo.user_role || '');
} catch (error) {
console.error('❌ [Index] 解析 localStorage 用户信息失败:', error);
}
}
}
}
}, [loaderData.userInfo, loaderData.userRole]);
// 打印用户角色
useEffect(() => {
// console.log('📋 [Index] 当前用户角色:', userRole);
// console.log('👤 [Index] 当前用户信息:', userInfo);
}, [userRole, userInfo]);
// 🔑 清除系统设置模式和交叉评查模式标志(当用户返回首页时)
useEffect(() => {
if (typeof window !== 'undefined') {
const settingsMode = sessionStorage.getItem('settingsMode');
const crossCheckingMode = sessionStorage.getItem('crossCheckingMode');
if (settingsMode === 'true') {
sessionStorage.removeItem('settingsMode');
// console.log('🔄 [Index] 清除系统设置模式标志');
}
if (crossCheckingMode === 'true') {
sessionStorage.removeItem('crossCheckingMode');
// console.log('🔄 [Index] 清除交叉评查模式标志');
}
}
}, []); // 只在组件挂载时执行一次
// 更新日期时间
useEffect(() => {
const updateDateTime = () => {
const now = dayjs();
// 格式化日期: YYYY/MM/DD
setCurrentDateTime({
date: now.format('YYYY/MM/DD'),
time: now.format('HH:mm:ss')
});
};
// 初始化时间
updateDateTime();
// 每秒更新一次
const timerID = setInterval(updateDateTime, 1000);
return () => clearInterval(timerID);
}, []);
// 处理模块点击
const handleModuleClick = (module: EntryModule) => {
// 提取文档类型 IDs
const typeIds = module.documentTypes.map((dt) => dt.id) || [];
if (module.requiresDocumentTypes && typeIds.length === 0) {
toastService.error('该入口尚未关联文档类型,无法进入');
console.warn('⚠️ [Index] 模块未关联文档类型:', module.name);
return;
}
if (typeof window !== 'undefined') {
// 🔑 清除各页面的筛选条件缓存(切换入口模块时重置)
sessionStorage.removeItem('rules.searchParams');
// 🔑 存储到 sessionStorage(用于客户端请求)
if (typeIds.length > 0) {
sessionStorage.setItem('documentTypeIds', JSON.stringify(typeIds));
// console.log('📝 [Index] 存储到客户端 sessionStorage:', typeIds);
} else {
// 清空文档类型数据
sessionStorage.removeItem('documentTypeIds');
}
// 存储模块信息
sessionStorage.setItem('selectedModuleId', String(module.id));
sessionStorage.setItem('selectedModuleName', module.name);
sessionStorage.setItem('selectedModulePicPath', module.iconPath || '')
}
const targetPath = module.targetPath || '/home';
if (targetPath === '/chat-with-llm/chat' && typeof window !== 'undefined') {
sessionStorage.setItem('selectedModulePicPath', module.iconPath || '/images/icon_assistant.png');
}
navigate(targetPath);
};
// 处理键盘事件
const handleKeyDown = (module: EntryModule, e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
handleModuleClick(module);
}
};
// 获取模块图标(根据模块 path 或 id)
const getModuleIcon = (module: EntryModule) => {
if (module.iconPath){
if (module.iconPath.startsWith('/images/')) {
return module.iconPath;
}
return `${DOCUMENT_URL}${module.iconPath}`;
}
return '/images/icon_assistant.png';
};
// 处理登出
const handleLogout = () => {
// 清除sessionStorage中的所有数据
if (typeof window !== 'undefined') {
sessionStorage.clear();
}
// 使用Form组件提交登出请求
const form = document.getElementById('logout-form') as HTMLFormElement;
if (form) {
form.submit();
} else {
// 如果找不到表单,直接导航到登录页
navigate('/login');
}
};
// 处理进入系统设置
const handleEnterSettings = () => {
// 🔑 检查是否有系统设置的子路由
if (!loaderData.settingsChildren || loaderData.settingsChildren.length === 0) {
// 没有子路由,显示错误提示
toastService.error('您无权限访问或页面丢失');
console.warn('⚠️ [Index] 系统设置没有可访问的子路由');
return;
}
if (typeof window !== 'undefined') {
// 🔑 设置标志:表示用户通过系统设置入口进入
sessionStorage.setItem('settingsMode', 'true');
// 清除模块相关的标志(因为不是从入口模块进入)
sessionStorage.removeItem('selectedModuleId');
sessionStorage.removeItem('selectedModuleName');
sessionStorage.removeItem('selectedModulePicPath');
// 清除交叉评查模式标志
sessionStorage.removeItem('crossCheckingMode');
}
// 跳转到第一个子路由
const firstChildPath = loaderData.settingsChildren[0].path;
console.log(`📌 [Index] 系统设置:跳转到第一个子路由 ${firstChildPath}`);
navigate(firstChildPath);
};
// 处理进入交叉评查
const handleEnterCrossChecking = () => {
if (typeof window !== 'undefined') {
// 🔑 设置标志:表示用户通过交叉评查入口进入
sessionStorage.setItem('crossCheckingMode', 'true');
sessionStorage.setItem('selectedModuleName', '交叉评查')
sessionStorage.setItem('selectedModulePicPath', '/images/icon_cross@2x.png')
// 清除模块相关的标志(因为不是从入口模块进入)
sessionStorage.removeItem('selectedModuleId');
// sessionStorage.removeItem('selectedModuleName');
// sessionStorage.removeItem('selectedModulePicPath');
// 清除系统设置模式标志
sessionStorage.removeItem('settingsMode');
}
// 跳转到交叉评查的默认页面
navigate('/cross-checking');
};
return (
<div className="home-page">
{/* 登出表单 - 隐藏 */}
<Form method="post" id="logout-form" className="hidden">
<input type="hidden" name="intent" value="logout" />
</Form>
{/* 头部 */}
<header className="header">
<div className="logo-container">
<img src="/logo.svg" alt="中国烟草" className="logo" />
<div className="flex flex-col">
<span className="logo-text "></span>
<span className="logo-text-en">CHINA TOBACCO</span>
</div>
</div>
<div className="user-info">
{/* 系统设置按钮 - 只在有权限且非交叉评查专属模式时显示 */}
{loaderData.hasSettingsAccess && !loaderData.isCrossCheckingOnlyMode && (
<button
onClick={handleEnterSettings}
className="settings-button"
aria-label="系统设置"
title="系统设置"
>
<i className="ri-settings-4-line"></i>
</button>
)}
<span className="datetime">{currentDateTime.date} {currentDateTime.time}</span>
<div className="user">
{(() => {
const displayName = (userInfo?.nick_name || (userInfo as { nickname?: string })?.nickname || (userInfo as { name?: string })?.name || '') as string;
const lastChar = displayName ? displayName.charAt(displayName.length - 1) : '用';
return (
<>
<div className="avatar w-10 h-10 rounded-full bg-primary text-white flex items-center justify-center">
<span>{lastChar}</span>
</div>
<span className="username ml-2">{displayName || '未知用户'}</span>
</>
);
})()}
<button
onClick={handleLogout}
className="logout-button"
aria-label="登出"
>
<i className="ri-logout-box-line"></i>
</button>
</div>
</div>
</header>
{/* 主要内容 */}
<main className="index-main-content">
<div className="index-main-content-container">
<h1 className="welcome-text">- -</h1>
{/* 模块网格区域 */}
<div className="modules-container">
{/* 🔒 交叉评查专属模式:只显示交叉评查入口 */}
{loaderData.isCrossCheckingOnlyMode ? (
loaderData.hasCrossCheckingAccess ? (
<div
className="module-card"
onClick={handleEnterCrossChecking}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleEnterCrossChecking();
}
}}
role="button"
tabIndex={0}
aria-label="交叉评查"
>
<img
src="/images/icon_cross@2x.png"
alt="交叉评查"
className="w-12 h-12 mx-1"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const parent = (e.target as HTMLImageElement).parentElement;
if (parent) {
const icon = document.createElement('i');
icon.className = 'ri-shuffle-line';
icon.style.fontSize = '48px';
icon.style.color = 'var(--color-primary)';
parent.insertBefore(icon, parent.firstChild);
}
}}
/>
<span className="module-name"></span>
</div>
) : (
<div className="text-center text-gray-500 py-8">
</div>
)
) : (
/* 正常模式:显示所有入口模块 */
loaderData.entryModules && loaderData.entryModules.length > 0 ? (
<>
{loaderData.entryModules.map((module: EntryModule) => {
const isLLMModule = module.targetPath === '/chat-with-llm/chat';
const isCrossCheckingModule = module.targetPath === '/cross-checking';
// 🔑 如果是智慧法务助手且用户没有访问权限,则不渲染该模块
if (isLLMModule && !loaderData.hasChatLLMAccess) {
return null;
}
// 交叉评查已在下面独立渲染,避免首页重复出现两张卡片
if (isCrossCheckingModule) {
return null;
}
return (
<div
key={module.id}
className="module-card"
onClick={() => handleModuleClick(module)}
onKeyDown={(e) => handleKeyDown(module, e)}
role="button"
tabIndex={0}
aria-label={module.name}
>
<img
src={isLLMModule ? '/images/icon_assistant.png' : getModuleIcon(module)}
alt={module.name}
className="w-12 h-12 mx-1"
/>
<span className="module-name">{module.name}</span>
</div>
);
})}
{/* 交叉评查入口 - 独立渲染,不依赖智慧法务助手模块 */}
{loaderData.hasCrossCheckingAccess && (
<div
className="module-card"
onClick={handleEnterCrossChecking}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleEnterCrossChecking();
}
}}
role="button"
tabIndex={0}
aria-label="交叉评查"
>
<img
src="/images/icon_cross@2x.png"
alt="交叉评查"
className="w-12 h-12 mx-1"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const parent = (e.target as HTMLImageElement).parentElement;
if (parent) {
const icon = document.createElement('i');
icon.className = 'ri-shuffle-line';
icon.style.fontSize = '48px';
icon.style.color = 'var(--color-primary)';
parent.insertBefore(icon, parent.firstChild);
}
}}
/>
<span className="module-name"></span>
</div>
)}
</>
) : (
<div className="text-center text-gray-500 py-8">
</div>
)
)}
</div>
</div>
</main>
</div>
);
}