Files
leaudit-platform-frontend/app/routes/home.tsx
T
LiangShiyong dab0835605 feat: 1.修改提示词模板的不用角色的操作权限。
2. 对接数据看板的数据。
3. 添加入口模块管理的页面。
2025-11-21 17:16:07 +08:00

680 lines
26 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 from 'react';
import { type MetaFunction } from "@remix-run/node";
import { useLoaderData, useNavigate, Form } from "@remix-run/react";
import { Card } from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import { FileTag, links as fileTagLinks } from "~/components/ui/FileTag";
// import { FileTypeTag, links as fileTypeTagLinks } from "~/components/ui/FileTypeTag";
import { Tag } from "~/components/ui/Tag";
import homeStyles from "~/styles/pages/sys_overview.css?url";
import { getDocumentsListFromAPI, type DocumentUI } from "~/api/files/documents";
import { useState, useEffect } from "react";
import { getHomeData, getTopErrorPoints, getTopRiskUsers, type TopErrorPointsResponse, type TopRiskUsersResponse } from "~/api/home/home";
import dayjs from 'dayjs';
// import type { UserRole } from '~/api/login/auth.server';
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { logout, getUserSession } from "~/api/login/auth.server";
// 文件处理状态选项
const fileProcessingStatusOptions = [
{ value: "Waiting", label: "上传中", icon: "ri-loader-line", color: "blue" },
{ value: "Cutting", label: "切分中", icon: "ri-loader-line", color: "purple" },
{ value: "Extractioning", label: "抽取中", icon: "ri-loader-line", color: "cyan" },
{ value: "Evaluationing", label: "评查中", icon: "ri-loader-line", color: "teal" },
{ value: "Processed", label: "已完成", icon: "ri-check-line", color: "green" },
];
export const links = () => [
{ rel: "stylesheet", href: homeStyles },
...fileTagLinks()
];
export const meta: MetaFunction = () => {
return [
{ title: "中国烟草AI合同及卷宗审核系统 - 首页" },
{ name: "description", content: "AI审核系统首页" }
];
};
// API 响应的类型定义
// interface StatsData {
// totalFiles: number;
// reviewedFiles: number;
// pendingFiles: number;
// passRate: number;
// }
// 添加认证检查
export async function loader({ request }: LoaderFunctionArgs) {
try {
// 从根loader获取用户角色
const { userRole, userInfo, frontendJWT, isAuthenticated } = await getUserSession(request);
// 🔑 检查用户是否已登录且有用户信息
if (!isAuthenticated || !userInfo) {
console.warn("⚠️ [Home Loader] 用户未登录或缺少用户信息,重定向到登录页");
const url = new URL(request.url);
return Response.redirect(`/login?redirect=${encodeURIComponent(url.pathname)}`, 302);
}
// 返回默认值,实际数据将在客户端根据 sessionStorage 加载
return Response.json({
homeData: {
todayPendingFiles: 0,
monthlyReviewedFiles: 0,
monthlyReviewGrowth: { value: 0, isUp: true },
monthlyPassRate: 0,
passRateGrowth: { value: 0, isUp: true },
issuesDetected: 0,
issuesGrowth: { value: 0, isUp: true }
},
recentFiles: [],
userRole: userRole,
userInfo,
frontendJWT
});
} catch (error) {
// 错误处理
console.error('Failed to fetch dashboard data:', error);
return Response.json(
{ error: '获取数据失败,请稍后重试' },
{ status: 500 }
);
}
}
// 处理登出请求
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 default function Home() {
const navigate = useNavigate();
const { homeData: initialHomeData, recentFiles: initialRecentFiles, userRole: serverUserRole, userInfo, frontendJWT } = useLoaderData<typeof loader>();
const [recentFiles, setRecentFiles] = useState<DocumentUI[]>(initialRecentFiles || []);
const [homeData, setHomeData] = useState(initialHomeData);
const [currentDateTime, setCurrentDateTime] = useState({
date: '',
time: ''
});
// 独立的loading状态管理
const [loadingStates, setLoadingStates] = useState({
stats: true, // 统计信息
recentFiles: true, // 最近文档
errorPoints: true, // 高频错误评查点
riskUsers: true // 高风险用户
});
// 统计数据状态(初始时标记为不可用,加载后根据API响应更新)
const [topErrorPoints, setTopErrorPoints] = useState<TopErrorPointsResponse>({ available: false, total: 0, items: [] });
const [topRiskUsers, setTopRiskUsers] = useState<TopRiskUsersResponse>({ available: false, total: 0, items: [] });
// 打印服务器端传递的用户角色
useEffect(() => {
console.log('服务器返回的用户角色:', serverUserRole);
}, [serverUserRole]);
// 更新当前时间
useEffect(() => {
// 使用dayjs格式化日期和时间
const updateDateTime = () => {
const now = dayjs();
setCurrentDateTime({
date: now.format('YYYY年MM月DD日'),
time: now.format('HH:mm:ss')
});
};
// 立即更新一次
updateDateTime();
// 设置计时器,每秒更新一次
const timerID = setInterval(updateDateTime, 1000);
// 清理函数,组件卸载时清除计时器
return () => clearInterval(timerID);
}, []);
// 处理登出操作
const handleLogout = () => {
// 清除sessionStorage中的所有数据
if (typeof window !== 'undefined') {
sessionStorage.removeItem('userRole');
sessionStorage.removeItem('documentTypeIds');
sessionStorage.removeItem('frontendJWT');
sessionStorage.removeItem('userInfo');
sessionStorage.removeItem('accessToken');
sessionStorage.removeItem('isAuthenticated');
// 可以根据需要清除其他会话数据
sessionStorage.clear();
}
// 使用Form组件提交登出请求
const form = document.getElementById('logout-form') as HTMLFormElement;
if (form) {
form.submit();
} else {
// 如果找不到表单,直接导航到登录页
navigate('/login');
}
};
// 在客户端挂载时,根据 sessionStorage 中的 documentTypeIds 加载正确的数据
useEffect(() => {
const loadData = async () => {
try {
// 从 sessionStorage 获取 documentTypeIds
const documentTypeIdsStr = sessionStorage.getItem('documentTypeIds');
const documentTypeIds = documentTypeIdsStr ? JSON.parse(documentTypeIdsStr) : [];
// 从 documentTypeIds 推断 reviewType(用于 getHomeData
const reviewType = inferReviewType(documentTypeIds);
// 并行加载所有数据,每个数据加载完成后立即更新对应的loading状态
await Promise.all([
// 加载统计信息
(async () => {
try {
const newHomeData = await getHomeData(reviewType, userInfo.user_id, frontendJWT);
setHomeData(newHomeData);
setLoadingStates(prev => ({ ...prev, stats: false }));
} catch (error) {
console.error('加载统计信息失败:', error);
setLoadingStates(prev => ({ ...prev, stats: false }));
}
})(),
// 加载最近文档
(async () => {
try {
const docs = await loadDocuments(documentTypeIds);
setRecentFiles(docs);
setLoadingStates(prev => ({ ...prev, recentFiles: false }));
} catch (error) {
console.error('加载最近文档失败:', error);
setLoadingStates(prev => ({ ...prev, recentFiles: false }));
}
})(),
// 加载高频错误评查点
(async () => {
try {
const errorPointsData = await getTopErrorPoints(10, undefined, undefined, documentTypeIds, frontendJWT);
setTopErrorPoints(errorPointsData);
setLoadingStates(prev => ({ ...prev, errorPoints: false }));
} catch (error) {
console.error('加载高频错误评查点失败:', error);
setLoadingStates(prev => ({ ...prev, errorPoints: false }));
}
})(),
// 加载高风险用户
(async () => {
try {
const riskUsersData = await getTopRiskUsers(5, undefined, undefined, documentTypeIds, frontendJWT);
setTopRiskUsers(riskUsersData);
setLoadingStates(prev => ({ ...prev, riskUsers: false }));
} catch (error) {
console.error('加载高风险用户失败:', error);
setLoadingStates(prev => ({ ...prev, riskUsers: false }));
}
})()
]);
} catch (error) {
console.error('加载数据失败:', error);
// 确保所有loading状态都被重置
setLoadingStates({
stats: false,
recentFiles: false,
errorPoints: false,
riskUsers: false
});
}
};
loadData();
}, []); // 仅在组件挂载时执行一次
// 从 documentTypeIds 推断 reviewType(用于 getHomeData API
const inferReviewType = (documentTypeIds: number[]): string | null => {
if (!documentTypeIds || documentTypeIds.length === 0) return null;
if (documentTypeIds.includes(1)) return 'contract';
if (documentTypeIds.includes(2) || documentTypeIds.includes(3)) return 'record';
return null;
};
// 加载文档数据的函数
const loadDocuments = async (documentTypeIds: number[]) => {
try {
if (!frontendJWT) {
console.error('缺少 JWT token');
return [];
}
const baseParams = {
page: 1,
pageSize: 10,
token: frontendJWT
};
// 直接使用 documentTypeIds 查询
if (documentTypeIds && documentTypeIds.length > 0) {
const response = await getDocumentsListFromAPI({
...baseParams,
documentTypeIds: documentTypeIds
});
if (!response.error && response.data) {
return response.data.documents;
}
} else {
// 没有指定类型,获取所有文档
const response = await getDocumentsListFromAPI(baseParams);
if (!response.error && response.data) {
return response.data.documents;
}
}
return []; // 默认返回空数组
} catch (error) {
console.error('加载文档数据失败:', error);
return [];
}
};
// 修改useEffect定时器,每10秒自动获取最近文档数据
// 按照定时器更新最近文档
// useEffect(() => {
// // 避免在加载状态下进行自动更新
// if (loadingStates.recentFiles) return;
// const fetchLatestDocuments = async () => {
// const documentTypeIdsStr = sessionStorage.getItem('documentTypeIds');
// const documentTypeIds = documentTypeIdsStr ? JSON.parse(documentTypeIdsStr) : [];
// const docs = await loadDocuments(documentTypeIds);
// setRecentFiles(docs);
// };
// // 设置10秒的定时器
// const timerID = setInterval(fetchLatestDocuments, 10000);
// // 组件卸载时清除定时器
// return () => {
// clearInterval(timerID);
// };
// }, [loadingStates.recentFiles]); // 仅依赖最近文档的loading状态
return (
<div className="dashboard-container">
{/* 登出表单 - 隐藏 */}
<Form method="post" id="logout-form" className="hidden">
<input type="hidden" name="intent" value="logout" />
</Form>
{/* 页面头部 */}
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-medium"></h2>
<div className="text-sm text-gray-500 flex flex-row items-center justify-between">
<div className="flex items-center">
<span id="current-date">{currentDateTime.date}</span>
<span className="mx-2">|</span>
<span id="current-time">{currentDateTime.time}</span>
</div>
<div className="user-profile p-4 border-b border-gray-100 flex items-center">
<div className="avatar w-10 h-10 rounded-full bg-primary text-white flex items-center justify-center">
<span>{userInfo.nick_name.charAt(userInfo.nick_name.length-1)}</span>
</div>
<div className="ml-1">
{/* <p className="text-sm font-medium mb-0">{userRole === 'developer' ? '系统管理员' : '普通用户'}</p> */}
<p className="text-sm font-medium mb-0">{userInfo.nick_name}</p>
{/* <p className="text-xs text-gray-500 mb-0">{userRole === 'developer' ? '超级管理员' : '标准权限'}</p> */}
</div>
</div>
{/* 登出操作 */}
<Button
type="default"
size="small"
className="ml-4 hover:bg-gray-100"
onClick={handleLogout}
>
<i className="ri-logout-box-line mr-1"></i>
</Button>
</div>
</div>
{/* 统计卡片区域 */}
<Card title="统计信息" icon="ri-bar-chart-line" className="transition-all duration-200 hover:shadow-[0_4px_15px_rgba(0,0,0,0.1)]">
{loadingStates.stats ? (
<LoadingSkeleton type="stats" />
) : (
<div className="stat-grid">
<StatCard
title="今日待审文件"
value={homeData.todayPendingFiles}
icon="ri-inbox-line"
/>
<StatCard
title="本月已审核文件"
value={homeData.monthlyReviewedFiles}
icon="ri-file-search-line"
trend={{ value: homeData.monthlyReviewGrowth.value, isUp: homeData.monthlyReviewGrowth.isUp }}
/>
<StatCard
title="本月审核通过率"
value={homeData.monthlyPassRate}
icon="ri-percent-line"
trend={{ value: homeData.passRateGrowth.value, isUp: homeData.passRateGrowth.isUp }}
/>
<StatCard
title="本月问题检出数"
value={homeData.issuesDetected}
icon="ri-error-warning-line"
trend={{ value: homeData.issuesGrowth.value, isUp: homeData.issuesGrowth.isUp }}
/>
</div>
)}
</Card>
{/* 快捷访问区域 */}
{/* <Card title="快捷访问" icon="ri-speed-line" className="mt-6 transition-all duration-200 hover:shadow-[0_4px_15px_rgba(0,0,0,0.1)]">
<div className="shortcut-grid">
<ShortcutItem icon="ri-upload-cloud-line" label="上传文件" to="/files/upload" />
<ShortcutItem icon="ri-file-list-3-line" label="文档列表" to="/documents/list" />
<ShortcutItem icon="ri-list-check-3" label="评查点列表" to="/rules" />
<ShortcutItem icon="ri-folder-open-line" label="评查点分组" to="/rule-groups" />
</div>
</Card> */}
{/* 高频错误评查点 */}
{topErrorPoints.available && (
<Card
title="高频错误评查点 Top 10"
icon="ri-error-warning-line"
className="mt-6"
>
{loadingStates.errorPoints ? (
<LoadingSkeleton type="table" rows={5} />
) : topErrorPoints.total > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="py-3 px-4 text-left font-medium text-gray-700"></th>
<th className="py-3 px-4 text-left font-medium text-gray-700"></th>
<th className="py-3 px-4 text-right font-medium text-gray-700"></th>
</tr>
</thead>
<tbody>
{topErrorPoints.items.map((item) => (
<tr key={item.evaluation_point_id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-3 px-4">
<span className={`inline-flex items-center justify-center w-6 h-6 rounded-full text-xs font-medium ${
item.rank === 1 ? 'bg-red-100 text-red-800' :
item.rank === 2 ? 'bg-orange-100 text-orange-800' :
item.rank === 3 ? 'bg-yellow-100 text-yellow-800' :
'bg-gray-100 text-gray-600'
}`}>
{item.rank}
</span>
</td>
<td className="py-3 px-4 text-gray-900">{item.point_name}</td>
<td className="py-3 px-4 text-right">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
<i className="ri-user-line mr-1"></i>
{item.error_user_count}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-gray-500">
<i className="ri-error-warning-line text-4xl mb-2"></i>
<p></p>
</div>
)}
</Card>
)}
{/* 高风险用户 */}
{topRiskUsers.available && (
<Card
title="高风险用户 Top 5"
icon="ri-shield-user-line"
className="mt-6"
>
{loadingStates.riskUsers ? (
<LoadingSkeleton type="table" rows={5} />
) : topRiskUsers.total > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="py-3 px-4 text-left font-medium text-gray-700"></th>
<th className="py-3 px-4 text-left font-medium text-gray-700"></th>
<th className="py-3 px-4 text-left font-medium text-gray-700"></th>
<th className="py-3 px-4 text-right font-medium text-gray-700"></th>
<th className="py-3 px-4 text-right font-medium text-gray-700"></th>
</tr>
</thead>
<tbody>
{topRiskUsers.items.map((item) => (
<tr key={item.user_id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-3 px-4">
<span className={`inline-flex items-center justify-center w-6 h-6 rounded-full text-xs font-medium ${
item.rank === 1 ? 'bg-red-100 text-red-800' :
item.rank === 2 ? 'bg-orange-100 text-orange-800' :
item.rank === 3 ? 'bg-yellow-100 text-yellow-800' :
'bg-gray-100 text-gray-600'
}`}>
{item.rank}
</span>
</td>
<td className="py-3 px-4 text-gray-900">{item.user_name}</td>
<td className="py-3 px-4 text-gray-600">{item.department}</td>
<td className="py-3 px-4 text-right">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
{item.total_errors}
</span>
</td>
<td className="py-3 px-4 text-right">
<span className="text-gray-600">{item.avg_errors_per_doc.toFixed(2)}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-gray-500">
<i className="ri-shield-user-line text-4xl mb-2"></i>
<p></p>
</div>
)}
</Card>
)}
{/* 最近文档区域 */}
<Card
title="最近文档"
icon="ri-file-list-3-line"
extra={!loadingStates.recentFiles && <Button to="/documents/list" size="small"></Button>}
className="mt-6"
>
{loadingStates.recentFiles ? (
<LoadingSkeleton type="list" rows={5} />
) : (
<div className="doc-list">
{recentFiles.length > 0 ? (
recentFiles.map((file: DocumentUI) => (
<div key={file.id} className="doc-item">
<div className="doc-info">
<FileTag
extension={file.name.endsWith('.pdf') ? 'pdf' : 'docx'}
showIcon={true}
showText={false}
showBackground={false}
size="lg"
className="mr-2"
/>
<div>
<div className="doc-name">{file.name}</div>
<div className="doc-meta">
<Tag size="sm" className="mr-2">
{file.typeName}
</Tag>
<span className="text-gray-500">·</span>
<span className="ml-2 text-gray-500">{file.updatedAt}</span>
</div>
</div>
</div>
<div className="doc-status">
{(() => {
const fileStatus = file.fileStatus || "-";
const status = fileProcessingStatusOptions.find(s => s.value === fileStatus) ||
fileProcessingStatusOptions[0];
const isSpinning = fileStatus !== "Processed";
return (
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs bg-${status.color}-100 text-${status.color}-800`}>
<i className={`${status.icon} ${isSpinning ? "animate-spin" : ""} mr-1`}></i>
<span>{status.label}</span>
</div>
);
})()}
</div>
</div>
))
) : (
<div className="text-center py-8 text-gray-500">
<i className="ri-file-list-3-line text-4xl mb-2"></i>
<p></p>
</div>
)}
</div>
)}
</Card>
</div>
);
}
// 统计卡片组件
interface StatCardProps {
title: string;
value: number | string;
icon: string;
trend?: {
value: number;
isUp: boolean;
};
}
function StatCard({ title, value, icon, trend }: StatCardProps) {
return (
<div className="stat-card">
<div className="stat-title">{title}</div>
<div className="stat-value">{value}</div>
{trend && (
<div className={`stat-trend ${title === '本月问题检出数' ? !trend.isUp? 'trend-up':'trend-down' : trend.isUp ? 'trend-up' : 'trend-down'}`}>
<i className={`mr-1 ${trend.isUp ? 'ri-arrow-up-s-line' : 'ri-arrow-down-s-line'}`}></i>
<span>{trend.value}%</span>
<span className="ml-1 text-gray-500"></span>
</div>
)}
<i className={`${icon} stat-icon`}></i>
</div>
);
}
// 快捷方式组件
interface ShortcutItemProps {
icon: string;
label: string;
to: string;
}
function ShortcutItem({ icon, label, to }: ShortcutItemProps) {
return (
<Button
to={to}
type="default"
className="shortcut-item"
>
<i className={`${icon} shortcut-icon text-2xl`}></i>
<span className="shortcut-label">{label}</span>
</Button>
);
}
// Loading骨架屏组件
interface LoadingSkeletonProps {
type?: 'stats' | 'table' | 'list';
rows?: number;
}
function LoadingSkeleton({ type = 'list', rows = 3 }: LoadingSkeletonProps) {
if (type === 'stats') {
return (
<div className="stat-grid">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="stat-card animate-pulse">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-4"></div>
<div className="h-8 bg-gray-200 rounded w-1/2 mb-2"></div>
<div className="h-3 bg-gray-200 rounded w-2/3"></div>
</div>
))}
</div>
);
}
if (type === 'table') {
return (
<div className="animate-pulse space-y-3">
{/* 表头 */}
<div className="flex gap-4 pb-3 border-b border-gray-200">
{[1, 2, 3].map((i) => (
<div key={i} className="h-4 bg-gray-200 rounded flex-1"></div>
))}
</div>
{/* 表格行 */}
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex gap-4 py-3 border-b border-gray-100">
{[1, 2, 3].map((j) => (
<div key={j} className="h-4 bg-gray-200 rounded flex-1"></div>
))}
</div>
))}
</div>
);
}
// 默认列表类型
return (
<div className="animate-pulse space-y-4">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex items-center gap-4 p-3 border border-gray-100 rounded">
<div className="h-10 w-10 bg-gray-200 rounded"></div>
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
</div>
<div className="h-6 w-20 bg-gray-200 rounded-full"></div>
</div>
))}
</div>
);
}