feat: 1.修改提示词模板的不用角色的操作权限。

2. 对接数据看板的数据。
3. 添加入口模块管理的页面。
This commit is contained in:
2025-11-21 17:16:07 +08:00
parent 3850d05bdd
commit dab0835605
13 changed files with 1877 additions and 297 deletions
+383 -192
View File
@@ -7,9 +7,9 @@ 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 { getDocuments, type DocumentUI, type DocumentSearchParams } from "~/api/files/documents";
import { getDocumentsListFromAPI, type DocumentUI } from "~/api/files/documents";
import { useState, useEffect } from "react";
import { getHomeData } from "~/api/home/home";
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";
@@ -69,7 +69,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
issuesGrowth: { value: 0, isUp: true }
},
recentFiles: [],
reviewType: null,
userRole: userRole,
userInfo,
frontendJWT
@@ -105,18 +104,19 @@ export default function Home() {
date: '',
time: ''
});
const [isLoading, setIsLoading] = useState(true);
// const userRole = serverUserRole as UserRole;
// 🔑 防御性检查:如果 userInfo 不存在,重定向到登录页(理论上不应该发生,因为 loader 已经检查了)
if (!userInfo) {
console.error("❌ [Home] userInfo 不存在,重定向到登录页");
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
return null;
}
// 独立的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);
@@ -148,8 +148,7 @@ export default function Home() {
// 清除sessionStorage中的所有数据
if (typeof window !== 'undefined') {
sessionStorage.removeItem('userRole');
sessionStorage.removeItem('reviewType');
sessionStorage.removeItem('previousReviewType');
sessionStorage.removeItem('documentTypeIds');
sessionStorage.removeItem('frontendJWT');
sessionStorage.removeItem('userInfo');
sessionStorage.removeItem('accessToken');
@@ -157,7 +156,7 @@ export default function Home() {
// 可以根据需要清除其他会话数据
sessionStorage.clear();
}
// 使用Form组件提交登出请求
const form = document.getElementById('logout-form') as HTMLFormElement;
if (form) {
@@ -168,82 +167,122 @@ export default function Home() {
}
};
// 在客户端挂载时,根据 sessionStorage 中的 reviewType 加载正确的数据
// 在客户端挂载时,根据 sessionStorage 中的 documentTypeIds 加载正确的数据
useEffect(() => {
const loadData = async () => {
try {
setIsLoading(true);
// 从 sessionStorage 获取 reviewType
const reviewType = sessionStorage.getItem('reviewType');
// 从 sessionStorage 获取 documentTypeIds
const documentTypeIdsStr = sessionStorage.getItem('documentTypeIds');
const documentTypeIds = documentTypeIdsStr ? JSON.parse(documentTypeIdsStr) : [];
// 加载主页数据
const newHomeData = await getHomeData(reviewType || undefined,userInfo.user_id, frontendJWT);
setHomeData(newHomeData);
// 加载文档数据
const docs = await loadDocuments(reviewType);
setRecentFiles(docs);
setIsLoading(false);
// 从 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);
setIsLoading(false);
// 确保所有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 (reviewType: string | null) => {
const loadDocuments = async (documentTypeIds: number[]) => {
try {
const documentSearchParams: DocumentSearchParams = {
if (!frontendJWT) {
console.error('缺少 JWT token');
return [];
}
const baseParams = {
page: 1,
pageSize: 10,
userId: userInfo.user_id,
token: frontendJWT || undefined
token: frontendJWT
};
// 根据 reviewType 添加过滤条件
if (reviewType === 'contract') {
documentSearchParams.documentType = '1';
const response = await getDocuments(documentSearchParams);
// 直接使用 documentTypeIds 查询
if (documentTypeIds && documentTypeIds.length > 0) {
const response = await getDocumentsListFromAPI({
...baseParams,
documentTypeIds: documentTypeIds
});
if (!response.error && response.data) {
// console.log('合同文档数据',response.data.documents);
return response.data.documents;
}
} else if (reviewType === 'record') {
// 获取类型 2 的文档
const response1 = await getDocuments({
...documentSearchParams,
documentType: '2'
});
// 获取类型 3 的文档
const response2 = await getDocuments({
...documentSearchParams,
documentType: '3'
});
if (!response1.error && !response2.error && response1.data && response2.data) {
// 合并文档并排序
const mergedDocs = [...response1.data.documents, ...response2.data.documents];
mergedDocs.sort((a, b) =>
new Date(b.updatedAt || '').getTime() - new Date(a.updatedAt || '').getTime()
);
// 限制数量
// console.log('卷宗文档数据',mergedDocs);
return mergedDocs.slice(0, documentSearchParams.pageSize);
}
} else {
// 没有定类型,获取所有文档
const response = await getDocuments(documentSearchParams);
// 没有定类型,获取所有文档
const response = await getDocumentsListFromAPI(baseParams);
if (!response.error && response.data) {
return response.data.documents;
}
}
return []; // 默认返回空数组
} catch (error) {
console.error('加载文档数据失败:', error);
@@ -251,63 +290,28 @@ export default function Home() {
}
};
// 监听 sessionStorage 中 reviewType 的变化
useEffect(() => {
const handleStorageChange = async () => {
const currentReviewType = sessionStorage.getItem('reviewType');
const previousReviewType = sessionStorage.getItem('previousReviewType');
// 如果 reviewType 发生变化
if (currentReviewType !== previousReviewType) {
setIsLoading(true);
// 更新主页数据
const newHomeData = await getHomeData(currentReviewType || undefined,userInfo.user_id, frontendJWT);
setHomeData(newHomeData);
// 更新文档数据
const docs = await loadDocuments(currentReviewType);
setRecentFiles(docs);
// 保存当前 reviewType 为上一次的值,用于比较
sessionStorage.setItem('previousReviewType', currentReviewType || '');
setIsLoading(false);
}
};
// 设置初始的 previousReviewType
const initialReviewType = sessionStorage.getItem('reviewType');
sessionStorage.setItem('previousReviewType', initialReviewType || '');
// 设置定期检查
const checkInterval = setInterval(handleStorageChange, 1000);
return () => {
clearInterval(checkInterval);
};
}, []);
// 修改useEffect定时器,每10秒自动获取最近文档数据
// 按照定时器更新最近文档
useEffect(() => {
// 避免在加载状态下进行自动更新
if (isLoading) return;
const fetchLatestDocuments = async () => {
const reviewType = sessionStorage.getItem('reviewType');
const docs = await loadDocuments(reviewType);
setRecentFiles(docs);
};
// 设置10秒的定时器
const timerID = setInterval(fetchLatestDocuments, 10000);
// 组件卸载时清除定时器
return () => {
clearInterval(timerID);
};
}, [isLoading]); // 仅依赖 isLoading 状态
// 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">
@@ -349,92 +353,220 @@ export default function Home() {
</div>
{/* 统计卡片区域 */}
<Card title="统计信息" icon="ri-bar-chart-line" className="mt-6 transition-all duration-200 hover:shadow-[0_4px_15px_rgba(0,0,0,0.1)]">
<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 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)]">
{/* <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>
</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={<Button to="/documents/list" size="small"></Button>}
extra={!loadingStates.recentFiles && <Button to="/documents/list" size="small"></Button>}
className="mt-6"
>
<div className="doc-list">
{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>
{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 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>
)}
</div>
)}
</Card>
</div>
);
}
@@ -476,9 +608,9 @@ interface ShortcutItemProps {
function ShortcutItem({ icon, label, to }: ShortcutItemProps) {
return (
<Button
to={to}
type="default"
<Button
to={to}
type="default"
className="shortcut-item"
>
<i className={`${icon} shortcut-icon text-2xl`}></i>
@@ -486,3 +618,62 @@ function ShortcutItem({ icon, label, to }: ShortcutItemProps) {
</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>
);
}