dab0835605
2. 对接数据看板的数据。 3. 添加入口模块管理的页面。
680 lines
26 KiB
TypeScript
680 lines
26 KiB
TypeScript
// 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>
|
||
);
|
||
}
|