feat: 1. 添加axios全局路由拦截进行自动添加请求jwt。 2.重新整理路由表。 3. 文档列表新增版本差异对比。 4.菜单路由可访问列表通过对接接口返回,添加全局路由检测。

5. 修改统一认证登录和管理员登录是通过接口形式进行,存储返回的accessToken。    6. 修改交叉评查的部分样式
This commit is contained in:
2025-11-18 11:06:24 +08:00
parent 8a50671c39
commit bfe39e45a9
53 changed files with 9503 additions and 2796 deletions
+34 -14
View File
@@ -28,38 +28,58 @@ export async function action({ request }: ActionFunctionArgs) {
return null;
}
// 验证用户登录状态
// 获取用户信息(不再检查服务端认证)
export async function loader({ request }: LoaderFunctionArgs) {
const { isAuthenticated, userRole, userInfo } = await getUserSession(request);
// ⚠️ 不再检查服务端 session 认证
// 认证检查由 ClientAuthGuard 在客户端进行
if (!isAuthenticated) {
return redirect("/login");
}
const { userRole, userInfo } = await getUserSession(request);
// 返回用户信息给客户端(可能为空)
return Response.json({ userRole, userInfo });
}
export default function Index() {
const navigate = useNavigate();
const { userRole, userInfo } = useLoaderData<typeof loader>();
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');
// setIsPort51707(window.location.port === '5178');
}
}, []);
// 打印服务器端传递的用户角色
// 如果服务端没有返回用户信息,从 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);
}, [userRole]);
console.log('📋 [Index] 当前用户角色:', userRole);
console.log('👤 [Index] 当前用户信息:', userInfo);
}, [userRole, userInfo]);
// 更新日期时间
useEffect(() => {
+115 -53
View File
@@ -1,8 +1,10 @@
import { type LoaderFunctionArgs, redirect } from "@remix-run/node";
import { createUserSession, saveUserInfo, sessionStorage } from "~/api/login/auth.server";
import { useEffect } from "react";
import { useSearchParams } from "@remix-run/react";
import { createUserSession, sessionStorage } from "~/api/login/auth.server";
import { OAuthClient } from "~/api/login/oauth-client";
import { getServerOAuthConfigRuntime } from "~/config/oauth-secret.server";
import { JWTUtils, type UserInfoForJWT } from "~/utils/jwt";
import { loginWithOAuth, type LoginRequest } from "~/api/login/login-client";
/**
* 端口号到地区的映射关系
@@ -49,6 +51,30 @@ export async function loader({ request }: LoaderFunctionArgs) {
const error = url.searchParams.get("error");
const error_description = url.searchParams.get("error_description");
// 🔑 检查是否是管理员账密登录(直接传递 token 和 userInfo
const token = url.searchParams.get("token");
const userInfo = url.searchParams.get("userInfo");
const redirectTo = url.searchParams.get("redirectTo") || "/";
// 🔑 如果有 token 和 userInfo,说明是管理员账密登录
// login.tsx action 已经创建了 Cookie Session,这里只需要返回 null
// 让客户端组件从 URL 读取 token 并保存到 localStorage
if (token && userInfo) {
console.log("✅ [Callback] 检测到管理员账密登录,交给客户端处理");
// 验证 userInfo 格式(防止解析错误)
try {
JSON.parse(decodeURIComponent(userInfo));
} catch (error) {
console.error("❌ [Callback] 解析管理员登录用户信息失败:", error);
return redirectToLoginWithError(request, "登录失败:用户信息格式错误");
}
// ✅ 直接返回 null,让客户端 useEffect 处理
// 注意:Cookie Session 已经在 login.tsx action 中通过 createUserSession 创建
return null;
}
// console.log("🔧 OAuth2.0回调参数:", {
// code: code ? `${code.substring(0, 10)}...` : null,
// state: state,
@@ -119,81 +145,79 @@ export async function loader({ request }: LoaderFunctionArgs) {
}
console.log("✅ [Callback] 用户信息获取成功");
// TODO 根据用户信息判断用户角色,这里可以根据实际业务逻辑调整 暂定都是common
const userRole = "common";
// 获取重定向URL
const redirectTo = url.searchParams.get("redirect") || "/";
// 🔒 安全:临时 JWT 现在在 saveUserInfo() 内部生成,避免在客户端代码中暴露 user_id 逻辑
// 成功获取用户信息之后通过auth.server.ts中的saveUserInfo方法去写入自己的数据库中,通过sub作为唯一值去添加数据
const saveResult = await saveUserInfo(
userInfo.data,
userRole,
tokenResponse.expires_in,
area
);
if (!saveResult.success) {
console.error("保存用户信息到数据库失败:", saveResult.error);
// 调用后端登录接口,传递 OAuth 用户信息,获取 JWT token
const loginRequest: LoginRequest = {
userInfo: userInfo.data,
expiresIn: tokenResponse.expires_in,
area: area
};
// 🔑 保存用户信息失败,需要清除 IDaaS 登录状态
const loginResponse = await loginWithOAuth(loginRequest);
if (!loginResponse.success || !loginResponse.data) {
console.error("❌ [Callback] 后端登录失败:", loginResponse.error);
// 🔑 登录失败,需要清除 IDaaS 登录状态
try {
const logoutUrl = `${url.protocol}//${url.host}/login`;
const logoutSuccess = await oauthClient.logout(tokenResponse.access_token, logoutUrl);
if (logoutSuccess) {
console.log("✅ [Callback] 已清除 IDaaS 登录状态(数据库保存失败)");
console.log("✅ [Callback] 已清除 IDaaS 登录状态(后端登录失败)");
} else {
console.warn("⚠️ [Callback] 清除 IDaaS 登录状态失败(数据库保存失败)");
console.warn("⚠️ [Callback] 清除 IDaaS 登录状态失败(后端登录失败)");
}
} catch (logoutError) {
console.error("❌ [Callback] 调用 IDaaS 登出时出错(数据库保存失败):", logoutError);
console.error("❌ [Callback] 调用 IDaaS 登出时出错(后端登录失败):", logoutError);
}
return redirectToLoginWithError(request, "保存用户信息失败,请重新登录");
return redirectToLoginWithError(request, loginResponse.error || "登录失败,请重新登录");
}
console.log("用户信息已成功保存到数据库,地区:", area || "未设置");
const savedUserData = saveResult.data!;
console.log("✅ [Callback] 后端登录成功,JWT token 已获取");
const frontendJWT = loginResponse.data.access_token;
const savedUserInfo = loginResponse.data.user_info;
// 生成前端专用JWT(使用完整的用户信息,包括数据库 ID
const jwtUserInfo: UserInfoForJWT = {
sub: userInfo.data.sub,
user_id: savedUserData.id!,
username: savedUserData.username,
nick_name: savedUserData.nick_name,
email: savedUserData.email,
phone_number: savedUserData.phone_number,
ou_id: savedUserData.ou_id,
ou_name: savedUserData.ou_name,
is_leader: savedUserData.is_leader,
user_role: userRole as 'common' | 'developer'
};
const frontendJWT = JWTUtils.generateJWT(jwtUserInfo, tokenResponse.expires_in);
// console.log("前端JWT已生成");
// 更新userInfo以包含数据库ID、JWT,并用数据库标准字段覆盖关键属性,确保 nick_name 等存在
// 更新userInfo以包含数据库ID、JWTuser_role 从后端返回
const enhancedUserInfo = {
...userInfo.data, // 保留OAuth返回的原字段(包含 nickname 等)
username: savedUserData.username,
nick_name: savedUserData.nick_name,
phone_number: savedUserData.phone_number,
email: savedUserData.email,
ou_id: savedUserData.ou_id,
ou_name: savedUserData.ou_name,
status: savedUserData.status,
is_leader: savedUserData.is_leader,
user_id: savedUserData.id,
user_role: userRole,
username: savedUserInfo.username,
nick_name: savedUserInfo.nick_name,
phone_number: savedUserInfo.phone_number,
email: savedUserInfo.email,
ou_id: savedUserInfo.ou_id,
ou_name: savedUserInfo.ou_name,
is_leader: savedUserInfo.is_leader,
user_id: savedUserInfo.user_id,
user_role: savedUserInfo.user_role, // 使用后端返回的角色
frontend_jwt: frontendJWT
};
// 🔑 重要:将 token 和用户信息作为 URL 参数传递给客户端
// 客户端 useEffect 会将其保存到 localStorage
const callbackUrl = new URL('/callback', url.origin);
callbackUrl.searchParams.set('token', frontendJWT);
callbackUrl.searchParams.set('userInfo', encodeURIComponent(JSON.stringify({
user_id: savedUserInfo.user_id,
username: savedUserInfo.username,
nick_name: savedUserInfo.nick_name,
email: savedUserInfo.email,
phone_number: savedUserInfo.phone_number,
ou_id: savedUserInfo.ou_id,
ou_name: savedUserInfo.ou_name,
is_leader: savedUserInfo.is_leader,
user_role: savedUserInfo.user_role,
sub: userInfo.data.sub
})));
callbackUrl.searchParams.set('redirectTo', redirectTo);
// 使用统一的session创建函数
return createUserSession({
isAuthenticated: true,
userRole: userRole,
redirectTo,
userRole: savedUserInfo.user_role, // 使用后端返回的角色
redirectTo: callbackUrl.toString(), // 先跳转到 callback 页面保存 token
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token,
tokenExpiresIn: tokenResponse.expires_in,
@@ -224,11 +248,49 @@ export async function loader({ request }: LoaderFunctionArgs) {
}
export default function Callback() {
const [searchParams] = useSearchParams();
useEffect(() => {
// 从 URL 参数中获取 token(如果有)
const token = searchParams.get("token");
const userInfo = searchParams.get("userInfo");
const redirectTo = searchParams.get("redirectTo") || "/";
if (token && typeof window !== 'undefined') {
console.log('🔑 [Callback] 开始保存 token 到 localStorage');
// 存储 token 到 localStorage
localStorage.setItem('access_token', token);
console.log('✅ [Callback] Token 已存储到 localStorage');
// 存储用户信息
if (userInfo) {
try {
const parsedUserInfo = JSON.parse(decodeURIComponent(userInfo));
localStorage.setItem('user_info', JSON.stringify(parsedUserInfo));
console.log('✅ [Callback] 用户信息已存储到 localStorage:', parsedUserInfo);
} catch (error) {
console.error('❌ [Callback] 解析用户信息失败:', error);
}
}
// ⏱️ 短暂延迟后跳转,确保 localStorage 写入完成
// 使用 requestAnimationFrame 确保 DOM 更新后立即跳转
requestAnimationFrame(() => {
setTimeout(() => {
console.log(`🚀 [Callback] 跳转到目标页面: ${redirectTo}`);
window.location.href = redirectTo;
}, 100); // 减少延迟从 500ms 到 100ms
});
}
}, [searchParams]);
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600">...</p>
<p className="mt-2 text-sm text-gray-500">...</p>
</div>
</div>
);
-152
View File
@@ -1,152 +0,0 @@
// import { useState } from 'react';
// import styles from '~/styles/pages/contract-search.css?url';
// export const links = () => [
// { rel: 'stylesheet', href: styles }
// ];
// export default function ContractSearchIndex() {
// const [searchQuery, setSearchQuery] = useState('');
// const handleSearchInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
// setSearchQuery(e.target.value);
// // 自动调整高度
// e.target.style.height = 'auto';
// e.target.style.height = Math.max(80, e.target.scrollHeight) + 'px';
// };
// const handleSearch = () => {
// if (searchQuery.trim()) {
// console.log('搜索查询:', searchQuery);
// // 这里可以添加实际的搜索逻辑
// }
// };
// const handleCategoryClick = (categoryName: string) => {
// console.log('选择分类:', categoryName);
// // 这里可以添加跳转到相应分类的逻辑
// };
// const handleKeyDown = (e: React.KeyboardEvent, categoryName: string) => {
// if (e.key === 'Enter' || e.key === ' ') {
// e.preventDefault();
// handleCategoryClick(categoryName);
// }
// };
// return (
// <div className="content-area">
// <div className="search-hero">
// <h1 className="search-title">AI智能合同模板搜索</h1>
// <p className="search-subtitle">输入合同名称、用途或关键内容,快速找到最适合的模板</p>
// <div className="search-container">
// <div className="search-box">
// <textarea
// className="search-textarea"
// placeholder="例如:销售合同、设备采购协议、包含违约责任条款的合同..."
// value={searchQuery}
// onChange={handleSearchInputChange}
// aria-label="搜索输入框"
// ></textarea>
// <div className="search-actions">
// <div className="search-tips">
// <i className="ri-lightbulb-line mr-1"></i>
// 支持自然语言描述,AI将为您匹配最相关的模板
// </div>
// <button
// className="search-btn"
// onClick={handleSearch}
// aria-label="开始搜索"
// >
// <i className="ri-search-line"></i>
// 智能搜索
// </button>
// </div>
// </div>
// </div>
// </div>
// <div className="quick-categories">
// <button
// className="category-card"
// onClick={() => handleCategoryClick('销售合同')}
// onKeyDown={(e) => handleKeyDown(e, '销售合同')}
// tabIndex={0}
// aria-label="销售合同分类"
// >
// <div className="category-icon">
// <i className="ri-handshake-line"></i>
// </div>
// <div className="category-title">销售合同</div>
// <div className="category-count">128个模板</div>
// </button>
// <button
// className="category-card"
// onClick={() => handleCategoryClick('采购合同')}
// onKeyDown={(e) => handleKeyDown(e, '采购合同')}
// tabIndex={0}
// aria-label="采购合同分类"
// >
// <div className="category-icon">
// <i className="ri-shopping-cart-line"></i>
// </div>
// <div className="category-title">采购合同</div>
// <div className="category-count">96个模板</div>
// </button>
// <button
// className="category-card"
// onClick={() => handleCategoryClick('物流运输')}
// onKeyDown={(e) => handleKeyDown(e, '物流运输')}
// tabIndex={0}
// aria-label="物流运输分类"
// >
// <div className="category-icon">
// <i className="ri-truck-line"></i>
// </div>
// <div className="category-title">物流运输</div>
// <div className="category-count">64个模板</div>
// </button>
// <button
// className="category-card"
// onClick={() => handleCategoryClick('人事劳务')}
// onKeyDown={(e) => handleKeyDown(e, '人事劳务')}
// tabIndex={0}
// aria-label="人事劳务分类"
// >
// <div className="category-icon">
// <i className="ri-user-settings-line"></i>
// </div>
// <div className="category-title">人事劳务</div>
// <div className="category-count">52个模板</div>
// </button>
// <button
// className="category-card"
// onClick={() => handleCategoryClick('租赁合同')}
// onKeyDown={(e) => handleKeyDown(e, '租赁合同')}
// tabIndex={0}
// aria-label="租赁合同分类"
// >
// <div className="category-icon">
// <i className="ri-building-line"></i>
// </div>
// <div className="category-title">租赁合同</div>
// <div className="category-count">38个模板</div>
// </button>
// <button
// className="category-card"
// onClick={() => handleCategoryClick('保密协议')}
// onKeyDown={(e) => handleKeyDown(e, '保密协议')}
// tabIndex={0}
// aria-label="保密协议分类"
// >
// <div className="category-icon">
// <i className="ri-shield-check-line"></i>
// </div>
// <div className="category-title">保密协议</div>
// <div className="category-count">24个模板</div>
// </button>
// </div>
// </div>
// );
// }
-23
View File
@@ -1,23 +0,0 @@
import { Outlet } from "@remix-run/react";
import { type MetaFunction } from "@remix-run/node";
export const meta: MetaFunction = () => {
return [
{ title: "智能搜索 - 中国烟草AI合同及卷宗审核系统" },
{
name: "contract-search",
content: "智能搜索模块,包括智能搜索功能"
}
];
};
export const handle = {
breadcrumb: "智能搜索"
};
/**
* 配置列表路由布局
*/
export default function ContractSearchLayout() {
return <Outlet />;
}
-56
View File
@@ -1,56 +0,0 @@
import { useLocation, Link } from "@remix-run/react";
export default function DebugPage() {
const location = useLocation();
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4"></h1>
<div className="bg-gray-100 p-4 rounded mb-6">
<h2 className="text-xl font-semibold mb-2"></h2>
<pre className="bg-white p-3 rounded border">
{JSON.stringify({
pathname: location.pathname,
search: location.search,
hash: location.hash,
key: location.key,
state: location.state
}, null, 2)}
</pre>
</div>
<div className="mb-6">
<h2 className="text-xl font-semibold mb-2"></h2>
<div className="flex flex-col space-y-2">
<Link to="/" className="text-blue-500 hover:underline"> - /</Link>
<Link to="/rules" className="text-blue-500 hover:underline"> - /rules</Link>
<Link to="/rules/1" className="text-blue-500 hover:underline"> - /rules/1</Link>
<a href="/rules" className="text-green-500 hover:underline"> - /rules</a>
</div>
</div>
<div>
<h2 className="text-xl font-semibold mb-2"></h2>
<button
onClick={() => {
window.location.href = '/rules';
}}
className="bg-blue-500 text-white px-4 py-2 rounded mr-2 hover:bg-blue-600"
>
/rules
</button>
<button
onClick={() => {
window.history.pushState({}, '', '/rules');
window.dispatchEvent(new PopStateEvent('popstate'));
}}
className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600"
>
使History API跳转到 /rules
</button>
</div>
</div>
);
}
+6 -6
View File
@@ -468,10 +468,10 @@ export default function DocumentEdit() {
<div className="page-header">
<h2 className="page-title"></h2>
<div>
<Button
type="default"
<Button
type="default"
icon="ri-arrow-left-line"
onClick={() => navigate("/documents")}
onClick={() => navigate("/documents/list")}
className="mr-2"
>
@@ -712,9 +712,9 @@ export function ErrorBoundary() {
<div className="error-container">
<h1 className="text-xl font-bold text-red-500 mb-4"></h1>
<p className="mb-4">ID是否正确</p>
<Button
type="primary"
to="/documents"
<Button
type="primary"
to="/documents/list"
>
</Button>
@@ -10,7 +10,9 @@ import { FileTag } from "~/components/ui/FileTag";
import { FilterPanel, FilterSelect, SearchFilter, DateRangeFilter } from "~/components/ui/FilterPanel";
import { TableRowSkeleton, LoadingIndicator, NumberSkeleton } from '~/components/ui/SkeletonScreen';
import documentsIndexStyles from "~/styles/pages/documents_index.css?url";
import { getDocuments, deleteDocument, type DocumentUI } from "~/api/files/documents";
import documentVersionStyles from "~/styles/components/document-version.css?url";
import { getDocuments, getDocumentsWithVersionInfo, getDocumentHistory, deleteDocument, type DocumentUI, type DocumentVersionUI } from "~/api/files/documents";
import { IssuesDiff } from "~/components/ui/IssuesDiff";
import { getDocumentTypes } from "~/api/document-types/document-types";
import { updateDocumentAuditStatus } from "~/api/evaluation_points/rules-files";
import { appendContractAttachments, uploadContractTemplate } from "~/api/files/files-upload";
@@ -22,7 +24,8 @@ import { DOCUMENT_URL } from "~/api/axios-client";
// 导入样式
export function links() {
return [
{ rel: "stylesheet", href: documentsIndexStyles }
{ rel: "stylesheet", href: documentsIndexStyles },
{ rel: "stylesheet", href: documentVersionStyles }
];
}
@@ -34,7 +37,7 @@ export const meta: MetaFunction = () => {
];
};
// 数据加载器
// 数据加载器
export const loader = async ({ request }: LoaderFunctionArgs) => {
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
@@ -45,7 +48,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const page = parseInt(url.searchParams.get("page") || "1", 10);
const pageSize = parseInt(url.searchParams.get("pageSize") || "10", 10);
// 获取文档类型列表,用于筛选条件
// 获取文档类型列表(服务端需要显式传递 token,客户端依赖 axios 拦截器)
const typesResponse = await getDocumentTypes({ pageSize: 500 }, frontendJWT);
const documentTypes = typesResponse.data?.types || [];
const documentTypeOptions = documentTypes.map(type => ({
@@ -61,7 +64,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
pageSize,
documentTypeOptions,
userInfo, // 传递用户信息到客户端
frontendJWT, // 传递前端JWT到客户端
initialLoad: true // 标记这是初始加载
});
};
@@ -180,19 +182,40 @@ export default function DocumentsIndex() {
const loaderData = useLoaderData<typeof loader>();
const fetcher = useFetcher<ActionResponse>();
const navigate = useNavigate();
// 存储从 sessionStorage 获取的 reviewType
const [reviewType, setReviewType] = useState<string | null>(null);
// 添加页面加载状态管理
const [isLoadingData, setIsLoadingData] = useState(true);
const [documents, setDocuments] = useState<DocumentUI[]>([]);
const [total, setTotal] = useState(0);
const [filteredDocumentTypeOptions, setFilteredDocumentTypeOptions] = useState(loaderData.documentTypeOptions);
const dataCache = useRef<typeof loaderData | null>(null);
// 添加一个状态来跟踪是否执行了删除操作
const [isDeleting, setIsDeleting] = useState(false);
// 辅助函数:从 localStorage 获取用户ID(与 token 管理保持一致)
const getUserId = useCallback((): string | undefined => {
if (typeof window === 'undefined') return undefined;
const userInfoStr = localStorage.getItem('user_info');
if (!userInfoStr) return undefined;
try {
const userInfoData = JSON.parse(userInfoStr);
return userInfoData.user_id?.toString();
} catch (error) {
console.error('解析 localStorage 用户信息失败:', error);
return undefined;
}
}, []);
// 版本管理:展开的文档行
const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set());
// 版本管理:正在加载历史版本的文档
const [loadingHistory, setLoadingHistory] = useState<Set<number>>(new Set());
// 附件追加和模板上传状态
const [showAttachmentUpload, setShowAttachmentUpload] = useState<boolean>(false);
@@ -240,12 +263,18 @@ export default function DocumentsIndex() {
const fetchData = useCallback(async (storedReviewType: string) => {
setIsLoadingData(true);
loadingBarService.show();
try {
// 从loader data中获取用户ID
const userId = loaderData.userInfo?.user_id?.toString();
// 构建搜索参数
// 从 localStorage 获取用户ID(与 token 管理保持一致)
const userId = getUserId();
if (!userId) {
toastService.error('用户身份验证失败,无法获取文档列表');
setIsLoadingData(false);
loadingBarService.hide();
return;
}
// 构建搜索参数(token 由 axios 拦截器自动从 localStorage 获取)
const searchParams = {
name: search || undefined,
documentNumber: documentNumber || undefined,
@@ -257,21 +286,20 @@ export default function DocumentsIndex() {
reviewType: storedReviewType || undefined,
userId: userId, // 添加用户ID筛选
page: currentPage,
pageSize,
token: loaderData.frontendJWT || undefined // 传递 JWT token
pageSize
};
// 获取文档列表
const documentsResponse = await getDocuments(searchParams);
// 获取文档列表(带版本信息)
const documentsResponse = await getDocumentsWithVersionInfo(searchParams);
if (documentsResponse.error) {
throw new Error(documentsResponse.error);
}
// 获取经过过滤的文档类型列表
const filteredTypesResponse = await getDocumentTypes({
pageSize: 500,
reviewType: storedReviewType || undefined
}, loaderData.frontendJWT || undefined);
// 获取经过过滤的文档类型列表token 由 axios 拦截器自动获取)
const filteredTypesResponse = await getDocumentTypes({
pageSize: 500,
reviewType: storedReviewType || undefined
});
const filteredDocumentTypes = filteredTypesResponse.data?.types || [];
const filteredOptions = filteredDocumentTypes.map(type => ({
value: type.id,
@@ -290,7 +318,7 @@ export default function DocumentsIndex() {
setIsLoadingData(false);
loadingBarService.hide();
}
}, [search, documentNumber, documentType, auditStatus, fileStatus, dateFrom, dateTo, currentPage, pageSize, loaderData.userInfo]);
}, [search, documentNumber, documentType, auditStatus, fileStatus, dateFrom, dateTo, currentPage, pageSize, getUserId]);
// 在组件挂载时从 sessionStorage 获取 reviewType 并加载数据
useEffect(() => {
@@ -707,17 +735,17 @@ export default function DocumentsIndex() {
// 开始审核
const handleReviewFileClick = async (fileId: number, auditStatus: number | null) => {
// 检查audit_status是否为0,如果是则更新为2
if (auditStatus === 0 || auditStatus === null) {
if (auditStatus === 0 || auditStatus === null) {
try {
// 从loader data中获取用户ID
const userId = loaderData.userInfo?.user_id?.toString();
// 从 localStorage 获取用户ID(与 token 管理保持一致)
const userId = getUserId();
if (!userId) {
toastService.error('用户身份验证失败');
return;
}
// console.log('开始审核',fileId,auditStatus)
const response = await updateDocumentAuditStatus(fileId.toString(), 2, userId, loaderData.frontendJWT as string | undefined);
const response = await updateDocumentAuditStatus(fileId.toString(), 2, userId);
if (response.error) {
console.error('更新文件审核状态失败:', response.error);
toastService.error('更新文件审核状态失败:' + (response.error || '未知错误'));
@@ -796,8 +824,7 @@ export default function DocumentsIndex() {
attachmentFiles,
attachmentMergeMode,
true, // isReprocess
attachmentRemark || undefined,
loaderData.frontendJWT as string | undefined
attachmentRemark || undefined
);
if (result.error) {
@@ -868,8 +895,7 @@ export default function DocumentsIndex() {
const result = await uploadContractTemplate(
templateFile,
selectedDocumentId,
undefined, // comparisonId
loaderData.frontendJWT as string | undefined
undefined // comparisonId
);
if (result.error) {
@@ -896,6 +922,165 @@ export default function DocumentsIndex() {
}
};
// 展开/折叠历史版本
const handleToggleExpand = async (doc: DocumentUI) => {
const newExpanded = new Set(expandedRows);
const newLoading = new Set(loadingHistory);
if (expandedRows.has(doc.id)) {
// 折叠:移除展开状态
newExpanded.delete(doc.id);
setExpandedRows(newExpanded);
// 清空历史版本数据
setDocuments(prevDocs =>
prevDocs.map(d =>
d.id === doc.id ? { ...d, historyVersions: undefined, isExpanded: false } : d
)
);
} else {
// 展开:加载历史版本
newExpanded.add(doc.id);
setExpandedRows(newExpanded);
// 如果还没有加载历史版本,则加载
if (!doc.historyVersions && doc.historyCount && doc.historyCount > 0) {
newLoading.add(doc.id);
setLoadingHistory(newLoading);
try {
// 从 localStorage 获取用户ID(与 token 管理保持一致)
const userId = getUserId();
if (!userId) {
toastService.error('用户身份验证失败');
newExpanded.delete(doc.id);
setExpandedRows(newExpanded);
newLoading.delete(doc.id);
setLoadingHistory(newLoading);
return;
}
const result = await getDocumentHistory(
doc.name,
userId,
doc.id
);
if (result.data) {
// 更新文档的历史版本数据
setDocuments(prevDocs =>
prevDocs.map(d =>
d.id === doc.id
? { ...d, historyVersions: result.data, isExpanded: true }
: d
)
);
} else if (result.error) {
toastService.error(`加载历史版本失败: ${result.error}`);
// 加载失败时取消展开
newExpanded.delete(doc.id);
setExpandedRows(newExpanded);
}
} catch (error) {
console.error('加载历史版本失败:', error);
toastService.error('加载历史版本失败');
newExpanded.delete(doc.id);
setExpandedRows(newExpanded);
} finally {
newLoading.delete(doc.id);
setLoadingHistory(newLoading);
}
} else {
// 已经加载过,只更新展开状态
setDocuments(prevDocs =>
prevDocs.map(d =>
d.id === doc.id ? { ...d, isExpanded: true } : d
)
);
}
}
};
// 渲染历史版本行的辅助函数
const renderHistoryRow = (historyDoc: DocumentVersionUI, parentDoc: DocumentUI) => {
return (
<tr key={`history-${historyDoc.id}`} className="history-row">
<td className="align-middle">
<input type="checkbox" disabled style={{ visibility: 'hidden' }} />
</td>
<td className="align-middle">
<div className="flex items-center justify-center gap-3">
<i className="ri-history-line text-gray-400 text-lg"></i>
<span className="history-version-label">
v{historyDoc.versionNumber}
</span>
</div>
</td>
<td className="text-xs text-gray-600 px-4">{historyDoc.documentNumber}</td>
<td className="text-xs text-gray-600 px-4">{formatFileSize(historyDoc.size)}</td>
<td className="px-4">
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs bg-green-100 text-green-800`}>
<i className="ri-check-line mr-1"></i>
<span>{fileProcessingStatusOptions.find(s => s.value === historyDoc.fileStatus)?.label || '已完成'}</span>
</div>
</td>
<td className="px-4">
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs ${
historyDoc.auditStatus === 1 ? 'bg-green-100 text-green-800' :
historyDoc.auditStatus === -1 ? 'bg-red-100 text-red-800' :
'bg-blue-100 text-blue-800'
}`}>
<i className={`${auditStatusMapping[historyDoc.auditStatus]?.icon || 'ri-time-line'} mr-1`}></i>
<span>{auditStatusMapping[historyDoc.auditStatus]?.label || '待审核'}</span>
</div>
</td>
<td className="px-4">
<IssuesDiff
currentIssues={historyDoc.issues}
previousIssues={undefined}
issuesDiff={historyDoc.issuesDiff}
issuesDiffType={historyDoc.issuesDiffType}
/>
</td>
<td className="text-xs text-gray-600 px-4">{historyDoc.uploadTime}</td>
<td className="">
<div className="operations-cell flex flex-wrap gap-1 px-4">
<Link
to={`/reviews?id=${historyDoc.id}&previousRoute=documents`}
className="text-xs px-2 py-1 h-7 mr-1 hover:underline"
>
<i className="ri-eye-line"></i>
</Link>
<Link
to={`/documents/edit?id=${historyDoc.id}`}
className="text-xs px-2 py-1 h-7 mr-1 text-gray-500 hover:underline hover:text-gray-700"
>
<i className="ri-edit-line"></i>
</Link>
<button
type="button"
className="text-xs px-2 py-1 h-7 mr-1 text-gray-500 hover:underline hover:text-gray-700"
onClick={() => handleDownload(historyDoc.path)}
>
<i className="ri-download-line"></i>
</button>
<button
type="button"
className="text-xs px-2 py-1 h-7 text-error hover:underline hover:text-red-700"
onClick={() => handleDelete(historyDoc.id.toString(), historyDoc.name, historyDoc.fileStatus)}
>
<i className="ri-delete-bin-line"></i>
</button>
</div>
</td>
</tr>
);
};
// 表格列定义
const columns = [
@@ -922,31 +1107,56 @@ export default function DocumentsIndex() {
key: "name",
width:'25%',
render: (_: unknown, record: DocumentUI) => (
<div className="flex m-1">
<FileTag
<div className="flex items-center gap-3">
{/* 展开/折叠图标(仅在有历史版本时显示) */}
{record.historyCount && record.historyCount > 0 ? (
loadingHistory.has(record.id) ? (
<i className="ri-loader-4-line expand-icon animate-spin" title="加载中..."></i>
) : (
<i
className={`ri-arrow-right-s-line expand-icon ${expandedRows.has(record.id) ? 'expanded' : ''}`}
onClick={() => handleToggleExpand(record)}
title={expandedRows.has(record.id) ? '折叠历史版本' : '展开历史版本'}
></i>
)
) : (
<span style={{ width: '20px', display: 'inline-block' }}></span>
)}
<FileTag
extension={record.fileType}
showIcon={true}
showText={false}
showIcon={true}
showText={false}
showBackground={false}
size="lg"
className="mr-2 flex-shrink-0 self-center"
className="flex-shrink-0"
/>
<div className="overflow-hidden">
<span className="file-name break-words block whitespace-normal leading-normal" title={record.name}>{record.name}</span>
<div className="mt-2 flex inline-block">
<FileTypeTag
type={record.type}
<div className="flex flex-col gap-2 flex-1 min-w-0">
<span className="doc-name-text font-medium text-gray-900" title={record.name}>
{record.name}
</span>
<div className="flex items-center gap-2 flex-wrap">
<FileTypeTag
type={record.type}
typeName={record.typeName}
text={record.typeName}
size="sm"
text={record.typeName}
size="sm"
showIcon={false}
fileType={record.fileType}
colorMode="light"
/>
{record.isTest && (
<span className="ml-2 text-xs bg-gray-100 text-gray-500 px-1 rounded"></span>
<span className="text-xs bg-gray-100 text-gray-500 px-1 rounded"></span>
)}
</div>
{/* 版本徽章 - 始终显示 */}
{record.historyCount !== undefined && record.historyCount > 0 ?
<span className="version-badge">
<i className="ri-history-line"></i>
v{record.historyCount + 1} {record.historyCount !== undefined && `(共${record.historyCount}个历史版本)`}
</span> : ""
}
</div>
</div>
</div>
)
@@ -1004,9 +1214,26 @@ export default function DocumentsIndex() {
{
title: "问题数量",
key: "issues",
width:"7%",
width:"10%",
render: (_: unknown, record: DocumentUI) => (
record.issues === null ? "-" : record.issues
<IssuesDiff
currentIssues={record.issues}
previousIssues={record.previousIssues}
issuesDiff={
record.issues != null && record.previousIssues != null
? Math.abs(record.issues - record.previousIssues)
: undefined
}
issuesDiffType={
record.issues != null && record.previousIssues != null
? record.issues > record.previousIssues
? 'increase'
: record.issues < record.previousIssues
? 'decrease'
: 'same'
: undefined
}
/>
)
},
{
@@ -1236,13 +1463,57 @@ export default function DocumentsIndex() {
<div className="overflow-x-auto">
{isLoadingData && documents.length === 0 ? (
<TableRowSkeleton count={5} />
) : documents.length === 0 ? (
<div className="text-center py-8 text-gray-500">
{isLoadingData ? "加载中..." : "暂无数据"}
</div>
) : (
<Table
columns={columns}
dataSource={documents}
rowKey="id"
emptyText={isLoadingData ? "加载中..." : "暂无数据"}
/>
<table className="w-full border-collapse">
<thead className="bg-gray-50">
<tr>
{columns.map((col, index) => (
<th
key={col.key || index}
style={{ width: col.width }}
className="px-4 py-3 text-left text-sm font-semibold text-gray-700 border-b"
>
{col.title}
</th>
))}
</tr>
</thead>
<tbody>
{documents.map((doc) => (
<>
{/* 主文档行 */}
<tr key={doc.id} className="border-b hover:bg-gray-50 transition-colors">
{columns.map((col, index) => (
<td key={col.key || index} className="px-4 py-3 text-sm">
{col.render ? col.render(null, doc, index) : (doc as any)[col.key]}
</td>
))}
</tr>
{/* 历史版本行 */}
{doc.isExpanded && doc.historyVersions && doc.historyVersions.length > 0 && (
<>
{doc.historyVersions.map((historyDoc) => renderHistoryRow(historyDoc, doc))}
</>
)}
{/* 正在加载历史版本 */}
{doc.isExpanded && loadingHistory.has(doc.id) && (
<tr key={`loading-${doc.id}`} className="history-row">
<td colSpan={columns.length} className="px-4 py-3">
<div className="version-loading">
<i className="ri-loader-4-line"></i>
...
</div>
</td>
</tr>
)}
</>
))}
</tbody>
</table>
)}
</div>
-632
View File
@@ -1,632 +0,0 @@
import { useState, useRef, useCallback } from "react";
import { type ActionFunctionArgs, type MetaFunction, json } from "@remix-run/node";
import { Form, useActionData, useNavigation, useSubmit } from "@remix-run/react";
import { Card } from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import { Alert } from "~/components/ui/Alert";
import { UploadArea, type UploadAreaRef } from "~/components/ui/UploadArea";
import { FileProgress } from "~/components/ui/FileProgress";
import { FileTag } from "~/components/ui/FileTag";
import documentUploadStyles from "~/styles/pages/document-upload.css?url";
export const links = () => [
{ rel: "stylesheet", href: documentUploadStyles }
];
export const meta: MetaFunction = () => {
return [
{ title: "上传文档 - 中国烟草AI合同及卷宗审核系统" },
{ name: "description", content: "上传文档进行AI审核" }
];
};
export const handle = {
breadcrumb: "上传文档"
};
// 模拟API支持的文件类型
const SUPPORTED_FILE_TYPES = [
{ id: "1", name: "销售合同" },
{ id: "2", name: "采购合同" },
{ id: "3", name: "专卖许可证" },
{ id: "4", name: "行政处罚决定书" },
{ id: "5", name: "承包协议" }
];
// 模拟API支持的存储类型
const STORAGE_TYPES = [
{ id: "minio", name: "MinIO对象存储" },
{ id: "local", name: "本地文件系统" },
{ id: "s3", name: "Amazon S3" }
];
// 文件上传完成后的操作选项
const AFTER_UPLOAD_OPTIONS = [
{ id: "list", name: "返回文档列表" },
{ id: "stay", name: "留在当前页面" },
{ id: "audit", name: "立即开始审核" }
];
// 定义接口
interface UploadedFile {
id: string;
name: string;
size: number;
status: "waiting" | "uploading" | "success" | "error";
progress: number;
error?: string;
newName?: string;
type: string;
}
interface ActionData {
success?: boolean;
error?: string;
files?: UploadedFile[];
}
// Action函数处理表单提交
export const action = async ({ request }: ActionFunctionArgs) => {
// 在实际应用中,这里应该处理文件上传逻辑
// 例如使用FormData API获取文件并调用后端API
try {
const formData = await request.formData();
const docType = formData.get("docType") as string;
const docNumber = formData.get("docNumber") as string;
const docRemark = formData.get("docRemark") as string;
const isTestDocument = formData.get("isTestDocument") === "true";
const storageType = formData.get("storageType") as string;
const afterUpload = formData.get("afterUpload") as string;
// 在真实情况下,这里将处理文件上传
// 由于Remix在服务器端不直接处理文件,我们将在客户端处理文件上传
// 然后将文件信息发送给服务器
// 模拟处理过程
await new Promise(resolve => setTimeout(resolve, 1000));
return json<ActionData>({
success: true,
files: [] // 服务器处理的文件列表将返回这里
});
} catch (error) {
console.error("Upload error:", error);
return json<ActionData>(
{
success: false,
error: error instanceof Error ? error.message : "文件上传过程中发生错误"
},
{ status: 400 }
);
}
};
// 格式化文件大小
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
// 获取文件扩展名
function getFileExtension(filename: string): string {
return filename.split('.').pop()?.toLowerCase() || "";
}
// 检查文件类型是否支持
function isFileTypeSupported(filename: string): boolean {
const ext = getFileExtension(filename);
return ["pdf", "doc", "docx", "txt"].includes(ext);
}
export default function DocumentUpload() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const submit = useSubmit();
const uploading = navigation.state === "submitting";
const [files, setFiles] = useState<UploadedFile[]>([]);
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const [isTestDocument, setIsTestDocument] = useState(false);
const [uploadComplete, setUploadComplete] = useState(false);
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
const uploadAreaRef = useRef<UploadAreaRef>(null);
const formRef = useRef<HTMLFormElement>(null);
// 处理文件选择
const handleFilesSelected = useCallback((fileList: FileList) => {
const newFiles: UploadedFile[] = [];
Array.from(fileList).forEach(file => {
// 检查文件类型
if (!isFileTypeSupported(file.name)) {
alert(`不支持的文件类型: ${file.name}\n请上传PDF、DOC、DOCX或TXT格式文件`);
return;
}
// 检查文件大小
if (file.size > 50 * 1024 * 1024) { // 50MB
alert(`文件过大: ${file.name}\n文件大小不能超过50MB`);
return;
}
// 检查是否已添加
const isDuplicate = files.some(f => f.name === file.name && f.size === file.size);
if (isDuplicate) {
alert(`文件已添加: ${file.name}`);
return;
}
// 添加新文件
newFiles.push({
id: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
name: file.name,
size: file.size,
status: "waiting",
progress: 0,
type: getFileExtension(file.name)
});
});
setFiles(prev => [...prev, ...newFiles]);
// 重置文件输入,允许再次选择相同文件
uploadAreaRef.current?.resetFileInput();
}, [files]);
// 移除文件
const removeFile = useCallback((fileId: string) => {
setFiles(prev => prev.filter(file => file.id !== fileId));
setSelectedFileIds(prev => prev.filter(id => id !== fileId));
}, []);
// 批量删除文件
const removeSelectedFiles = useCallback(() => {
if (selectedFileIds.length === 0) return;
if (confirm(`确定要删除选中的 ${selectedFileIds.length} 个文件吗?`)) {
setFiles(prev => prev.filter(file => !selectedFileIds.includes(file.id)));
setSelectedFileIds([]);
}
}, [selectedFileIds]);
// 清空文件列表
const clearAllFiles = useCallback(() => {
if (files.length === 0) return;
if (confirm('确定要清空文件列表吗?')) {
setFiles([]);
setSelectedFileIds([]);
}
}, [files.length]);
// 切换文件选择
const toggleFileSelection = useCallback((fileId: string, selected: boolean) => {
if (selected) {
setSelectedFileIds(prev => [...prev, fileId]);
} else {
setSelectedFileIds(prev => prev.filter(id => id !== fileId));
}
}, []);
// 更新文件名
const updateFileName = useCallback((fileId: string, newName: string) => {
setFiles(prev =>
prev.map(file =>
file.id === fileId
? { ...file, newName: newName + '.' + getFileExtension(file.name) }
: file
)
);
}, []);
// 提交表单
const handleSubmit = useCallback((event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = event.currentTarget;
const docType = form.docType.value;
// 表单验证
if (!docType) {
alert('请选择文档类型');
return;
}
if (files.length === 0) {
alert('请至少上传一个文档');
return;
}
// 创建FormData对象
const formData = new FormData(form);
formData.append("isTestDocument", isTestDocument.toString());
// 在实际应用中,这里应该处理文件上传
// 如果Remix不能直接处理文件上传,可以考虑使用预签名URL或其他方法
// 这里我们模拟文件上传进度
simulateUpload();
// 提交表单
submit(formData, { method: "post", encType: "multipart/form-data" });
}, [files.length, isTestDocument, submit]);
// 模拟文件上传进度
const simulateUpload = useCallback(() => {
const updatedFiles = [...files];
// 设置所有文件为上传中状态
updatedFiles.forEach(file => {
file.status = "uploading";
file.progress = 0;
});
setFiles(updatedFiles);
// 模拟进度更新
const interval = setInterval(() => {
setFiles(prevFiles => {
const newFiles = [...prevFiles];
let allComplete = true;
newFiles.forEach(file => {
if (file.status === "uploading") {
// 增加进度
file.progress += Math.random() * 10;
if (file.progress >= 100) {
file.progress = 100;
// 模拟有10%概率上传失败
if (Math.random() > 0.9) {
file.status = "error";
file.error = "上传失败,请重试";
} else {
file.status = "success";
}
} else {
allComplete = false;
}
}
});
// 如果所有文件都完成了,停止定时器
if (allComplete) {
clearInterval(interval);
setTimeout(() => {
// 检查是否有文件上传错误
const hasErrors = newFiles.some(file => file.status === "error");
if (!hasErrors) {
setUploadComplete(true);
}
}, 1000);
}
return newFiles;
});
}, 200);
}, [files]);
// 重新上传文件
const retryUpload = useCallback((fileId: string) => {
setFiles(prev =>
prev.map(file =>
file.id === fileId
? { ...file, status: "uploading", progress: 0, error: undefined }
: file
)
);
// 模拟重新上传
setTimeout(() => {
setFiles(prev =>
prev.map(file => {
if (file.id === fileId) {
const success = Math.random() > 0.1;
return {
...file,
status: success ? "success" : "error",
progress: 100,
error: success ? undefined : "上传失败,请重试"
};
}
return file;
})
);
}, 2000);
}, []);
// 重置表单,继续上传
const resetForm = useCallback(() => {
setFiles([]);
setUploadComplete(false);
setSelectedFileIds([]);
formRef.current?.reset();
}, []);
return (
<div className="document-upload-page">
<div className="page-header">
<h2 className="page-title"></h2>
<div>
<Button to="/documents" type="default" className="mr-2">
<i className="ri-arrow-left-line"></i>
</Button>
<Button
type="primary"
disabled={files.length === 0 || uploading}
onClick={() => formRef.current?.requestSubmit()}
>
<i className="ri-upload-2-line"></i>
</Button>
</div>
</div>
<Card>
{!uploadComplete ? (
<Form ref={formRef} method="post" onSubmit={handleSubmit} encType="multipart/form-data">
<div className="form-grid">
<div className="form-group">
<label className="form-label" htmlFor="docType">
<span className="text-red-500">*</span>
</label>
<select
id="docType"
name="docType"
className="form-select w-full"
required
>
<option value=""></option>
{SUPPORTED_FILE_TYPES.map(type => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
</select>
<div className="form-tip"></div>
</div>
<div className="form-group">
<label className="form-label" htmlFor="docNumber">
</label>
<input
type="text"
id="docNumber"
name="docNumber"
className="form-input w-full"
placeholder="请输入合同编号、许可证号等"
/>
<div className="form-tip"></div>
</div>
</div>
<div className="form-group">
<label className="form-label" htmlFor="docRemark">
</label>
<textarea
id="docRemark"
name="docRemark"
className="form-textarea w-full"
placeholder="可输入文档的相关描述或备注信息"
rows={2}
></textarea>
</div>
<div className="form-group">
<label className="form-label">
<span className="text-red-500">*</span>
</label>
<UploadArea
ref={uploadAreaRef}
onFilesSelected={handleFilesSelected}
accept=".pdf,.doc,.docx,.txt"
multiple={true}
icon="ri-upload-cloud-line"
mainText="拖拽文件到此处或点击上传"
tipText="支持 PDF、DOC、DOCX、TXT 格式文档,单个文件大小不超过50MB"
disabled={uploading}
/>
<div className="switch-container">
<label className="switch">
<input
type="checkbox"
checked={isTestDocument}
onChange={e => setIsTestDocument(e.target.checked)}
/>
<span className="slider"></span>
</label>
<span></span>
</div>
{files.length > 0 && (
<div className="batch-actions">
<div>
<span className="text-sm"> {selectedFileIds.length} </span>
</div>
<div>
<Button
type="default"
size="small"
className="mr-2"
onClick={removeSelectedFiles}
disabled={selectedFileIds.length === 0 || uploading}
>
<i className="ri-delete-bin-line"></i>
</Button>
<Button
type="default"
size="small"
onClick={clearAllFiles}
disabled={files.length === 0 || uploading}
>
<i className="ri-close-circle-line"></i>
</Button>
</div>
</div>
)}
<div className="file-list">
{files.map(file => (
<div key={file.id} className="file-item">
<input
type="checkbox"
checked={selectedFileIds.includes(file.id)}
onChange={e => toggleFileSelection(file.id, e.target.checked)}
disabled={uploading || file.status === "uploading"}
className="mr-3"
/>
<FileTag
extension={getFileExtension(file.name)}
size="lg"
className="mr-3"
/>
<div className="file-info">
<div className="file-name flex items-center">
<span>{file.newName || file.name}</span>
{file.status !== "uploading" && (
<button
type="button"
className="ml-2 text-primary text-sm"
onClick={() => {
const fileName = file.name;
const nameWithoutExt = fileName.substring(0, fileName.lastIndexOf('.'));
const newName = prompt('编辑文件名', nameWithoutExt);
if (newName) {
updateFileName(file.id, newName);
}
}}
disabled={uploading}
>
<i className="ri-edit-line"></i>
</button>
)}
</div>
<div className="file-meta">
<span className="file-size">{formatFileSize(file.size)}</span>
<span className={`file-status ${file.status === "error" ? "text-red-500" : ""}`}>
{file.status === "waiting" && "等待上传"}
{file.status === "uploading" && "上传中..."}
{file.status === "success" && "上传成功"}
{file.status === "error" && (
<>
{file.error}
<button
type="button"
className="ml-2 text-primary text-xs"
onClick={() => retryUpload(file.id)}
>
<i className="ri-refresh-line"></i>
</button>
</>
)}
</span>
</div>
<div className="progress-bar">
<div className="progress-bar-inner" style={{ width: `${file.progress}%` }}></div>
</div>
</div>
<div className="file-actions">
<Button
type="text"
size="small"
className="text-red-500"
onClick={() => removeFile(file.id)}
disabled={uploading || file.status === "uploading"}
title="删除文件"
>
<i className="ri-delete-bin-line"></i>
</Button>
</div>
</div>
))}
</div>
</div>
<div className="advanced-options">
<div
className={`advanced-options-toggle ${showAdvancedOptions ? 'open' : ''}`}
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
>
<span></span>
<i className="ri-arrow-down-s-line"></i>
</div>
<div
className="advanced-options-content"
style={{ display: showAdvancedOptions ? 'block' : 'none' }}
>
<div className="grid grid-cols-2 gap-4">
<div className="form-group">
<label className="form-label" htmlFor="storageType"></label>
<select
id="storageType"
name="storageType"
className="form-select w-full"
defaultValue="minio"
>
{STORAGE_TYPES.map(type => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
</select>
<div className="form-tip"></div>
</div>
<div className="form-group">
<label className="form-label" htmlFor="afterUpload"></label>
<select
id="afterUpload"
name="afterUpload"
className="form-select w-full"
defaultValue="list"
>
{AFTER_UPLOAD_OPTIONS.map(option => (
<option key={option.id} value={option.id}>
{option.name}
</option>
))}
</select>
<div className="form-tip"></div>
</div>
</div>
</div>
</div>
</Form>
) : (
<div className="upload-complete-actions" style={{ display: "block" }}>
<Alert type="success" className="mb-4">
</Alert>
<div>
<Button type="default" className="mr-2" onClick={resetForm}>
<i className="ri-add-line"></i>
</Button>
<Button to="/documents" type="default" className="mr-2">
<i className="ri-list-check-line"></i>
</Button>
<Button to="/documents/1?action=audit" type="primary">
<i className="ri-play-circle-line"></i>
</Button>
</div>
</div>
)}
</Card>
</div>
);
}
-236
View File
@@ -1,236 +0,0 @@
import { Tooltip } from '../../components/ui/Tooltip';
/**
* Tooltip 组件示例
* 展示不同主题、位置和风格的提示框
*/
export function TooltipExample() {
return (
<div className="tooltip-examples p-4">
<h2 className="text-lg font-bold mb-4">Tooltip </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* 基础提示框 */}
<div className="example-section">
<h3 className="text-base font-semibold mb-2"></h3>
<div className="flex space-x-4 mb-4">
<Tooltip content="这是一个基础提示框">
<button className="px-3 py-1 bg-gray-200 rounded hover:bg-gray-300">
</button>
</Tooltip>
<Tooltip content="点击显示的提示框" trigger="click">
<button className="px-3 py-1 bg-gray-200 rounded hover:bg-gray-300">
</button>
</Tooltip>
</div>
</div>
{/* 不同位置 */}
<div className="example-section">
<h3 className="text-base font-semibold mb-2"></h3>
<div className="flex space-x-4 mb-4">
<Tooltip content="顶部提示" placement="top">
<button className="px-3 py-1 bg-gray-200 rounded hover:bg-gray-300">
</button>
</Tooltip>
<Tooltip content="底部提示" placement="bottom">
<button className="px-3 py-1 bg-gray-200 rounded hover:bg-gray-300">
</button>
</Tooltip>
<Tooltip content="左侧提示" placement="left">
<button className="px-3 py-1 bg-gray-200 rounded hover:bg-gray-300">
</button>
</Tooltip>
<Tooltip content="右侧提示" placement="right">
<button className="px-3 py-1 bg-gray-200 rounded hover:bg-gray-300">
</button>
</Tooltip>
</div>
</div>
{/* 不同主题 */}
<div className="example-section">
<h3 className="text-base font-semibold mb-2"></h3>
<div className="flex flex-wrap gap-2">
<Tooltip content="深色主题" theme="dark">
<span className="px-3 py-1 border border-gray-300 rounded cursor-default">
</span>
</Tooltip>
<Tooltip content="浅色主题" theme="light">
<span className="px-3 py-1 border border-gray-300 rounded cursor-default">
</span>
</Tooltip>
<Tooltip content="主题色" theme="primary">
<span className="px-3 py-1 border border-gray-300 rounded cursor-default">
</span>
</Tooltip>
<Tooltip content="成功提示" theme="success">
<span className="px-3 py-1 border border-gray-300 rounded cursor-default">
</span>
</Tooltip>
<Tooltip content="警告提示" theme="warning">
<span className="px-3 py-1 border border-gray-300 rounded cursor-default">
</span>
</Tooltip>
<Tooltip content="错误提示" theme="error">
<span className="px-3 py-1 border border-gray-300 rounded cursor-default">
</span>
</Tooltip>
<Tooltip content="信息提示" theme="info">
<span className="px-3 py-1 border border-gray-300 rounded cursor-default">
</span>
</Tooltip>
</div>
</div>
{/* 富文本提示框 */}
<div className="example-section">
<h3 className="text-base font-semibold mb-2"></h3>
<div className="flex space-x-4">
<Tooltip
content={
<div>
<div className="flex items-center mb-1">
<span className="w-24 text-gray-500">CPU使用率:</span>
<span className="text-green-500 font-medium">32%</span>
</div>
<div className="flex items-center mb-1">
<span className="w-24 text-gray-500">使:</span>
<span className="text-yellow-500 font-medium">76%</span>
</div>
<div className="flex items-center">
<span className="w-24 text-gray-500">:</span>
<span className="text-blue-500 font-medium">245GB/500GB</span>
</div>
</div>
}
theme="light"
rich={true}
header="系统性能报告"
footer="更新时间: 2023-10-15 15:30:42"
showArrow={true}
>
<button className="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600">
</button>
</Tooltip>
<Tooltip
content={
<div>
<div className="flex items-center justify-between mb-1">
<span>:</span>
<span className="text-green-400 font-medium">¥258,432</span>
</div>
<div className="flex items-center justify-between mb-1">
<span>:</span>
<span className="text-green-400 font-medium">+15.8%</span>
</div>
<div className="flex items-center justify-between">
<span>:</span>
<span className="text-yellow-400 font-medium"></span>
</div>
</div>
}
theme="dark"
rich={true}
header="销售数据分析"
footer="数据来源: 销售管理系统"
placement="bottom"
>
<button className="px-3 py-1 bg-green-500 text-white rounded hover:bg-green-600">
</button>
</Tooltip>
</div>
</div>
{/* 条件渲染示例 */}
<div className="example-section">
<h3 className="text-base font-semibold mb-2"></h3>
<div className="flex space-x-4">
<Tooltip
content="优秀:得分在90分以上"
theme="success"
>
<span className="text-green-500 font-bold px-2">95</span>
</Tooltip>
<Tooltip
content="良好:得分在60-90分之间"
theme="warning"
>
<span className="text-yellow-500 px-2">75</span>
</Tooltip>
<Tooltip
content="不及格:得分低于60分"
theme="error"
>
<span className="text-red-500 px-2">45</span>
</Tooltip>
</div>
</div>
{/* 自定义样式示例 */}
<div className="example-section">
<h3 className="text-base font-semibold mb-2"></h3>
<div className="flex space-x-4">
<Tooltip
content="带有自定义宽度的提示框"
maxWidth={150}
>
<span className="border-b border-dotted border-gray-500 cursor-help">
</span>
</Tooltip>
<Tooltip
content="没有箭头的提示框"
showArrow={false}
theme="primary"
>
<span className="border-b border-dotted border-blue-500 cursor-help">
</span>
</Tooltip>
<Tooltip
content="自定义类名"
className="custom-tooltip"
>
<span className="border-b border-dotted border-purple-500 cursor-help text-purple-500">
</span>
</Tooltip>
</div>
</div>
</div>
</div>
);
}
export default TooltipExample;
-204
View File
@@ -1,204 +0,0 @@
import { useState } from 'react';
import { MessageModal, messageService, MessageModalProvider } from '~/components/ui/MessageModal';
import type { MessageType } from '~/components/ui/MessageModal';
import { LinksFunction } from '@remix-run/node';
import messageModalStyles from '~/styles/components/message-modal.css?url';
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: messageModalStyles },
];
export default function MessageModalExample() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalType, setModalType] = useState<MessageType>('info');
const [modalTitle, setModalTitle] = useState('');
const [modalMessage, setModalMessage] = useState('');
const [withConfirm, setWithConfirm] = useState(false);
const [autoClose, setAutoClose] = useState(false);
// 打开普通模态框
const openModal = (type: MessageType, title: string, message: string) => {
setModalType(type);
setModalTitle(title);
setModalMessage(message);
setIsModalOpen(true);
};
// 打开各类型的服务提示框
const showSuccessMessage = () => {
messageService.success('操作成功完成!', {
title: '成功提示',
autoClose: true
});
};
const showErrorMessage = () => {
messageService.error('操作过程中发生错误,请重试。', {
title: '错误提示'
});
};
const showWarningMessage = () => {
messageService.warning('此操作可能产生不可逆转的结果。', {
title: '警告提示',
onConfirm: () => {
messageService.success('您已确认继续操作')
},
confirmText: '继续操作',
cancelText: '取消'
});
};
const showInfoMessage = () => {
messageService.info('系统将于今晚10点进行升级维护。', {
title: '通知',
autoClose: true,
autoCloseDelay: 5000
});
};
const showCustomMessage = () => {
messageService.show({
title: '自定义消息',
message: '这是一个带有自定义内容的消息',
type: 'info',
confirmText: '了解',
children: (
<div style={{
padding: '10px',
backgroundColor: '#f0f0f0',
borderRadius: '6px',
marginTop: '10px'
}}>
<p></p>
<div style={{
display: 'flex',
alignItems: 'center',
marginTop: '10px'
}}>
<i className="ri-information-line" style={{ marginRight: '8px', color: '#1890ff' }}></i>
<span></span>
</div>
</div>
)
});
};
// 处理确认
const handleConfirm = () => {
messageService.success('您点击了确认按钮!', { autoClose: true });
setIsModalOpen(false);
};
return (
<MessageModalProvider>
<div className="container mx-auto p-6">
<h1 className="text-2xl font-bold mb-6"></h1>
<div className="bg-white rounded-lg p-6 shadow-md mb-8">
<h2 className="text-xl font-semibold mb-4"></h2>
<p className="mb-4">使MessageModal组件来控制模态框的显示和隐藏</p>
<div className="flex flex-wrap gap-3 mb-6">
<button
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
onClick={() => openModal('info', '信息提示', '这是一个普通的信息提示框')}
>
</button>
<button
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
onClick={() => openModal('success', '成功提示', '操作已成功完成')}
>
</button>
<button
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
onClick={() => openModal('error', '错误提示', '操作失败,请检查输入')}
>
</button>
<button
className="px-4 py-2 bg-yellow-500 text-white rounded hover:bg-yellow-600"
onClick={() => openModal('warning', '警告提示', '此操作将删除数据,是否继续?')}
>
</button>
</div>
<div className="flex flex-wrap gap-4 mb-4">
<label className="flex items-center">
<input
type="checkbox"
checked={withConfirm}
onChange={(e) => setWithConfirm(e.target.checked)}
className="mr-2"
/>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={autoClose}
onChange={(e) => setAutoClose(e.target.checked)}
className="mr-2"
/>
(3)
</label>
</div>
<MessageModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title={modalTitle}
message={modalMessage}
type={modalType}
autoClose={autoClose}
onConfirm={withConfirm ? handleConfirm : undefined}
confirmText={withConfirm ? "确认" : "我知道了"}
cancelText="取消"
/>
</div>
<div className="bg-white rounded-lg p-6 shadow-md mb-8">
<h2 className="text-xl font-semibold mb-4"></h2>
<p className="mb-4">使messageService可以在任何组件中方便地显示消息提示</p>
<div className="flex flex-wrap gap-3">
<button
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
onClick={showSuccessMessage}
>
()
</button>
<button
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
onClick={showErrorMessage}
>
</button>
<button
className="px-4 py-2 bg-yellow-500 text-white rounded hover:bg-yellow-600"
onClick={showWarningMessage}
>
()
</button>
<button
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
onClick={showInfoMessage}
>
(5)
</button>
<button
className="px-4 py-2 bg-purple-500 text-white rounded hover:bg-purple-600"
onClick={showCustomMessage}
>
</button>
</div>
</div>
</div>
</MessageModalProvider>
);
}
-192
View File
@@ -1,192 +0,0 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Spin, Tooltip, Input } from 'antd';
import {
LeftOutlined,
RightOutlined,
PlusCircleOutlined,
MinusCircleOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
CloseCircleOutlined,
ExclamationCircleOutlined,
RotateLeftOutlined,
RotateRightOutlined,
UnorderedListOutlined,
} from '@ant-design/icons';
import './index.less';
import { Document, Page, pdfjs } from 'react-pdf';
import pdfjsWorker from 'react-pdf/dist/esm/pdf.worker.entry';
pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorker;
const PDFView = ({
file,
parentDom,
onClose,
}: {
file?: string | null;
parentDom?: HTMLDivElement | null;
onClose?: () => void;
}) => {
const defaultWidth = 600;
const pageDiv = useRef<HTMLDivElement>(null);
const [numPages, setNumPages] = useState<number>(0);
const [pageNumber, setPageNumber] = useState<number>(1);
const [pageWidth, setPageWidth] = useState<number>(defaultWidth);
const [fullscreen, setFullscreen] = useState<boolean>(false);
const [rotation, setRotation] = useState<number>(0);
const [showThumbnails, setShowThumbnails] = useState<boolean>(false);
const [visiblePages, setVisiblePages] = useState<number[]>([1]); // 控制可见页面
const parent = parentDom || document.body;
// 加载 PDF 元信息,不渲染全部页面
const onDocumentLoadSuccess = useCallback(({ numPages }: { numPages: number }) => {
setNumPages(numPages);
}, []);
const lastPage = () => pageNumber > 1 && setPageNumber(pageNumber - 1);
const nextPage = () => pageNumber < numPages && setPageNumber(pageNumber + 1);
const onPageNumberChange = (e: { target: { value: string } }) => {
let value = Math.max(1, Math.min(numPages, Number(e.target.value) || 1));
setPageNumber(value);
setVisiblePages([value]); // 只加载当前页
};
const pageZoomIn = () => setPageWidth(pageWidth * 1.2);
const pageZoomOut = () => pageWidth > defaultWidth && setPageWidth(pageWidth * 0.8);
const pageFullscreen = () => {
setPageWidth(fullscreen ? defaultWidth : parent.offsetWidth - 50);
setFullscreen(!fullscreen);
};
const rotateLeft = () => setRotation((prev) => (prev - 90) % 360);
const rotateRight = () => setRotation((prev) => (prev + 90) % 360);
const toggleThumbnails = () => setShowThumbnails(!showThumbnails);
// 动态更新可见页面
useEffect(() => {
if (!showThumbnails) {
setVisiblePages([pageNumber]);
} else {
// 缩略图模式下限制加载数量,避免卡顿
const start = Math.max(1, pageNumber - 2);
const end = Math.min(numPages, pageNumber + 2);
setVisiblePages(Array.from({ length: end - start + 1 }, (_, i) => start + i));
}
}, [pageNumber, showThumbnails, numPages]);
useEffect(() => setPageNumber(1), [file]);
useEffect(() => {
if( pageDiv.current){
(pageDiv.current.scrollTop = 0)
}
}, [pageNumber]);
const renderContent=()=>(<div className='view'>
<div className='viewContent' >
<div className='pageMain' ref={pageDiv}>
<div className='pageContainer'>
<Document
file={file}
onLoadSuccess={onDocumentLoadSuccess}
error={
<div style={{ textAlign: 'center', width: defaultWidth + 'px' }}>
<ExclamationCircleOutlined style={{ fontSize: '150px', color: '#fe725c', margin: '100px' }} />
</div>
}
loading={<div style={{ textAlign: 'center', width: defaultWidth + 'px' }}><Spin size="large" style={{ margin: '200px' }} /></div>}
>
{showThumbnails ? (
<div className='thumbnailContainer'>
{Array.from({ length: numPages }, (_, i) => i + 1).map((page) => (
<div
key={page}
className='thumbnail'
onClick={() => {
setPageNumber(page);
setShowThumbnails(false);
}}
>
{visiblePages.includes(page) ? (
<Page
pageNumber={page}
width={150}
rotate={rotation}
loading={<Spin />}
renderTextLayer={false} // 禁用文本层,提升性能
renderAnnotationLayer={false} // 禁用注释层
/>
) : (
<div className='thumbnailPlaceholder'> {page} </div>
)}
<span> {page} </span>
</div>
))}
</div>
) : (
<Page
pageNumber={pageNumber}
width={pageWidth}
rotate={rotation}
loading={<Spin size="large" />}
renderTextLayer={false} // 禁用文本层
renderAnnotationLayer={false} // 禁用注释层
error={() => setPageNumber(1)}
/>
)}
</Document>
</div>
</div>
<div className='pageBar'>
<div className='pageTool'>
<Tooltip title={pageNumber === 1 ? '已是第一页' : '上一页'}>
<LeftOutlined onClick={lastPage} />
</Tooltip>
<Input
value={pageNumber}
onChange={onPageNumberChange}
onPressEnter={onPageNumberChange as any}
type="number"
/>{' '}
/ {numPages}
<Tooltip title={pageNumber === numPages ? '已是最后一页' : '下一页'}>
<RightOutlined onClick={nextPage} />
</Tooltip>
<Tooltip title="放大">
<PlusCircleOutlined onClick={pageZoomIn} />
</Tooltip>
<Tooltip title="缩小">
<MinusCircleOutlined onClick={pageZoomOut} />
</Tooltip>
<Tooltip title="向左旋转">
<RotateLeftOutlined onClick={rotateLeft} />
</Tooltip>
<Tooltip title="向右旋转">
<RotateRightOutlined onClick={rotateRight} />
</Tooltip>
<Tooltip title={showThumbnails ? '关闭缩略图' : '显示缩略图'}>
<UnorderedListOutlined onClick={toggleThumbnails} />
</Tooltip>
<Tooltip title={fullscreen ? '恢复默认' : '适合窗口'}>
{fullscreen ? <FullscreenExitOutlined onClick={pageFullscreen} /> : <FullscreenOutlined onClick={pageFullscreen} />}
</Tooltip>
{onClose && (
<Tooltip title="关闭">
<CloseCircleOutlined onClick={onClose} />
</Tooltip>
)}
</div>
</div>
</div>
</div>)
if(parentDom){
return renderContent()
}
return createPortal(
renderContent(),
parent,)
};
export default PDFView;
-164
View File
@@ -1,164 +0,0 @@
import { useState } from 'react';
import { Toast, toastService } from '~/components/ui/Toast';
import type { ToastType } from '~/components/ui/Toast';
import { LinksFunction } from '@remix-run/node';
import toastStyles from '~/styles/components/toast.css?url';
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: toastStyles },
];
export default function ToastExample() {
const [isToastOpen, setIsToastOpen] = useState(false);
const [toastType, setToastType] = useState<ToastType>('info');
const [toastMessage, setToastMessage] = useState('');
const [autoClose, setAutoClose] = useState(true);
// 打开普通通知
const openToast = (type: ToastType, message: string) => {
setToastType(type);
setToastMessage(message);
setIsToastOpen(true);
};
// 使用服务显示不同类型通知
const showSuccessToast = () => {
toastService.success('操作成功完成!');
};
const showErrorToast = () => {
toastService.error('操作过程中发生错误,请重试。');
};
const showWarningToast = () => {
toastService.warning('此操作可能产生不可逆转的结果。');
};
const showInfoToast = () => {
toastService.info('系统将于今晚10点进行升级维护。');
};
// 显示多行文本的长通知
const showLongToast = () => {
toastService.info('这是一个具有很长内容的通知,将自动换行以适应容器宽度,并且最多显示三行,超出部分会被截断。系统会自动处理长文本的换行和截断,确保显示效果一致。');
};
// 短时间内显示多个通知
const showMultipleToasts = () => {
toastService.success('第一条通知');
setTimeout(() => {
toastService.info('第二条通知');
}, 300);
setTimeout(() => {
toastService.warning('第三条通知');
}, 600);
setTimeout(() => {
toastService.error('第四条通知');
}, 900);
};
return (
<div className="container mx-auto p-6">
<h1 className="text-2xl font-bold mb-6"></h1>
<div className="bg-white rounded-lg p-6 shadow-md mb-8">
<h2 className="text-xl font-semibold mb-4"></h2>
<p className="mb-4">使Toast组件来控制通知的显示和隐藏</p>
<div className="flex flex-wrap gap-3 mb-6">
<button
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
onClick={() => openToast('info', '这是一个信息通知')}
>
</button>
<button
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
onClick={() => openToast('success', '操作已成功完成')}
>
</button>
<button
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
onClick={() => openToast('error', '操作失败,请检查输入')}
>
</button>
<button
className="px-4 py-2 bg-yellow-500 text-white rounded hover:bg-yellow-600"
onClick={() => openToast('warning', '请注意,这是一个警告通知')}
>
</button>
</div>
<div className="mb-4">
<label className="flex items-center">
<input
type="checkbox"
checked={autoClose}
onChange={(e) => setAutoClose(e.target.checked)}
className="mr-2"
/>
(3)
</label>
</div>
<Toast
isOpen={isToastOpen}
onClose={() => setIsToastOpen(false)}
message={toastMessage}
type={toastType}
autoClose={autoClose}
/>
</div>
<div className="bg-white rounded-lg p-6 shadow-md mb-8">
<h2 className="text-xl font-semibold mb-4"></h2>
<p className="mb-4">使toastService可以在任何组件中方便地显示通知</p>
<div className="flex flex-wrap gap-3 mb-6">
<button
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
onClick={showSuccessToast}
>
</button>
<button
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
onClick={showErrorToast}
>
</button>
<button
className="px-4 py-2 bg-yellow-500 text-white rounded hover:bg-yellow-600"
onClick={showWarningToast}
>
</button>
<button
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
onClick={showInfoToast}
>
</button>
</div>
<div className="flex flex-wrap gap-3">
<button
className="px-4 py-2 bg-purple-500 text-white rounded hover:bg-purple-600"
onClick={showLongToast}
>
</button>
<button
className="px-4 py-2 bg-pink-500 text-white rounded hover:bg-pink-600"
onClick={showMultipleToasts}
>
</button>
</div>
</div>
</div>
);
}
+6 -6
View File
@@ -366,17 +366,17 @@ export default function Home() {
<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" />
<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
title="最近文档"
icon="ri-file-list-3-line"
extra={<Button to="/documents" size="small"></Button>}
<Card
title="最近文档"
icon="ri-file-list-3-line"
extra={<Button to="/documents/list" size="small"></Button>}
className="mt-6"
>
<div className="doc-list">
+172 -76
View File
@@ -1,9 +1,10 @@
import { useEffect, useState } from "react";
import { useActionData, useLoaderData, Form } from "@remix-run/react";
import { useLoaderData, useNavigate, useFetcher } from "@remix-run/react";
import { type MetaFunction, type LoaderFunctionArgs, type ActionFunctionArgs, redirect } from "@remix-run/node";
import { OAuthClient } from "~/api/login/oauth-client";
import { CLIENT_OAUTH_CONFIG } from "~/config/api-config";
import { getUserSession, getSession, simpleRootLogin } from "~/api/login/auth.server";
import { getUserSession, getSession, createUserSession } from "~/api/login/auth.server";
import { loginWithPassword } from "~/api/login/login-client";
import styles from "~/styles/pages/login.css?url";
import { toastService } from "~/components/ui";
@@ -18,31 +19,24 @@ export const meta: MetaFunction = () => {
];
};
// 加载器,获取当前会话状态
// 加载器,获取重定向URL和错误信息
export async function loader({ request }: LoaderFunctionArgs) {
const { isAuthenticated } = await getUserSession(request);
// 如果已登录,重定向到首页
if (isAuthenticated) {
return redirect("/");
}
// ⚠️ 不再检查服务端 session 认证
// 认证检查改为在客户端通过 localStorage 进行
// 获取重定向URL并保存到session
// 获取重定向URL
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirect") || "/";
const session = await getSession(request);
// 读取 flash 消息(来自 callback 的错误)
const loginError = session.get("loginError");
session.set("redirectTo", redirectTo);
// 提交 session 以清除 flash 消息
if (loginError) {
const { sessionStorage } = await import("~/api/login/auth.server");
return Response.json({
isAuthenticated: false,
return Response.json({
redirectTo,
flashError: loginError
}, {
@@ -51,65 +45,141 @@ export async function loader({ request }: LoaderFunctionArgs) {
}
});
}
return Response.json({
isAuthenticated: false,
return Response.json({
redirectTo,
flashError: null
});
}
// 处理表单提交的action函数
// 处理管理员账密登录
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
const username = formData.get("username")?.toString().trim();
const password = formData.get("password")?.toString().trim();
try {
const formData = await request.formData();
const username = formData.get("username") as string;
const password = formData.get("password") as string;
const redirectTo = formData.get("redirectTo") as string || "/";
if (intent === "password_login") {
// 获取重定向目标
const session = await getSession(request);
const redirectTo = session.get("redirectTo") || "/";
// 调用 simpleRootLogin 方法进行登录
const response = await simpleRootLogin(username || "", password || "", redirectTo);
// 检查响应状态
if (response.status === 302) {
// 登录成功,直接返回重定向响应
return response;
} else {
// 登录失败,返回错误信息(不再使用URL参数)
const errorData = await response.json();
// 验证输入
if (!username?.trim()) {
return Response.json({
success: false,
error: errorData.error || "登录失败",
retryCount: errorData.retryCount || 0,
isLocked: errorData.isLocked || false,
remainingAttempts: errorData.remainingAttempts || 5
}, {
status: response.status
});
error: "请输入用户名"
}, { status: 400 });
}
}
return null;
if (!password?.trim()) {
return Response.json({
success: false,
error: "请输入密码"
}, { status: 400 });
}
console.log("📝 [Login Action] 开始处理管理员登录:", { username });
// 调用后端登录接口
const response = await loginWithPassword(username.trim(), password.trim());
if (!response.success || !response.data) {
console.error("❌ [Login Action] 登录失败:", response.error);
return Response.json({
success: false,
error: response.error || "登录失败,请检查用户名和密码"
}, { status: 401 });
}
const { access_token, user_info } = response.data;
// 验证返回数据
if (!access_token) {
console.error("❌ [Login Action] 后端未返回 access_token");
return Response.json({
success: false,
error: "登录失败:未获取到认证令牌"
}, { status: 500 });
}
if (!user_info) {
console.error("❌ [Login Action] 后端未返回 user_info");
return Response.json({
success: false,
error: "登录失败:未获取到用户信息"
}, { status: 500 });
}
console.log("✅ [Login Action] 登录成功,准备创建 session");
console.log("👤 [Login Action] 用户角色:", user_info.user_role); // 应该是 "admin"
// 获取当前 URL 用于构建 callback URL
const url = new URL(request.url);
// 🔑 重要:将 token 和用户信息作为 URL 参数传递给客户端
// 复用 OAuth 登录的 callback 页面逻辑
const callbackUrl = new URL('/callback', url.origin);
callbackUrl.searchParams.set('token', access_token);
callbackUrl.searchParams.set('userInfo', encodeURIComponent(JSON.stringify({
user_id: user_info.user_id,
username: user_info.username,
nick_name: user_info.nick_name,
email: user_info.email,
phone_number: user_info.phone_number,
ou_id: user_info.ou_id,
ou_name: user_info.ou_name,
is_leader: user_info.is_leader,
user_role: user_info.user_role,
sub: user_info.sub
})));
callbackUrl.searchParams.set('redirectTo', redirectTo);
// ✅ 使用统一的 session 创建函数(和 OAuth 登录一样)
return createUserSession({
isAuthenticated: true,
userRole: user_info.user_role,
redirectTo: callbackUrl.toString(), // 先跳转到 callback 页面保存 token
frontendJWT: access_token, // 保存到 Cookie Session
userInfo: {
user_id: user_info.user_id,
username: user_info.username,
nick_name: user_info.nick_name,
email: user_info.email,
phone_number: user_info.phone_number,
ou_id: user_info.ou_id,
ou_name: user_info.ou_name,
is_leader: user_info.is_leader,
user_role: user_info.user_role,
sub: user_info.sub
}
});
} catch (error) {
console.error("❌ [Login Action] 处理登录时发生异常:", error);
return Response.json({
success: false,
error: error instanceof Error ? error.message : "登录失败,请稍后重试"
}, { status: 500 });
}
}
export default function Login() {
const actionData = useActionData<typeof action>();
const navigate = useNavigate();
const loaderData = useLoaderData<typeof loader>();
const fetcher = useFetcher<{ success: boolean; error?: string }>();
const [isFlipped, setIsFlipped] = useState(false);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
// 从 actionData 或 loaderData 中获取错误信息
// actionData 的错误优先(来自密码登录)
// loaderData.flashError 次之(来自 OAuth 回调)
const error = actionData?.error || loaderData?.flashError;
const isLocked = actionData?.isLocked || false;
const retryCount = actionData?.retryCount || 0;
const remainingAttempts = actionData?.remainingAttempts || 5;
const [passwordLoginError, setPasswordLoginError] = useState<string | null>(null);
// 从 loaderData 中获取 OAuth 回调的错误信息
const oauthError = loaderData?.flashError;
// 显示的错误信息:密码登录错误优先,其次是 OAuth 错误
const error = passwordLoginError || oauthError;
const isLocked = false; // 可以从后端响应中获取
const retryCount = 0;
const remainingAttempts = 5;
// 监听 fetcher 的状态
const isLoading = fetcher.state === "submitting" || fetcher.state === "loading";
// 处理OAuth2.0登录
const handleOAuthLogin = () => {
@@ -148,29 +218,55 @@ export default function Login() {
// 处理账号密码登录表单提交
const handlePasswordLoginSubmit = (e: React.FormEvent) => {
e.preventDefault();
// 清除之前的错误
setPasswordLoginError(null);
// 检查账户是否被锁定
if (isLocked) {
e.preventDefault();
toastService.error("账户已被锁定,请联系管理员");
return;
}
// 客户端验证
if (!username.trim()) {
e.preventDefault();
toastService.error("请输入用户名");
return;
}
if (!password.trim()) {
e.preventDefault();
toastService.error("请输入密码");
return;
}
// 验证通过,让表单正常提交
console.log("📝 [Login] 提交管理员登录表单");
// ✅ 使用 fetcher 提交表单到服务端 action
const formData = new FormData();
formData.append("username", username.trim());
formData.append("password", password.trim());
formData.append("redirectTo", loaderData?.redirectTo || "/");
fetcher.submit(formData, {
method: "post",
action: "/login"
});
};
// 处理 fetcher 响应
useEffect(() => {
if (fetcher.data) {
if (!fetcher.data.success && fetcher.data.error) {
// 登录失败,显示错误
console.error("❌ [Login] 登录失败:", fetcher.data.error);
setPasswordLoginError(fetcher.data.error);
toastService.error(fetcher.data.error);
}
// 登录成功的情况由 action 中的 redirect 处理,会自动跳转到 callback 页面
}
}, [fetcher.data]);
useEffect(() => {
// 检查OAuth配置是否完整(客户端不需要检查 clientSecret
if (!CLIENT_OAUTH_CONFIG.serverUrl || !CLIENT_OAUTH_CONFIG.clientId) {
@@ -279,9 +375,7 @@ export default function Login() {
</div>
)}
<Form method="post" className="admin-login-form" onSubmit={handlePasswordLoginSubmit}>
<input type="hidden" name="intent" value="password_login" />
<form className="admin-login-form" onSubmit={handlePasswordLoginSubmit}>
<div className="form-group">
<label htmlFor="username" className="form-label"></label>
<input
@@ -292,10 +386,11 @@ export default function Login() {
onChange={(e) => setUsername(e.target.value)}
className="form-input"
placeholder="请输入用户名"
disabled={isLoading}
required
/>
</div>
<div className="form-group">
<label htmlFor="password" className="form-label"></label>
<input
@@ -306,18 +401,19 @@ export default function Login() {
onChange={(e) => setPassword(e.target.value)}
className="form-input"
placeholder="请输入密码"
disabled={isLoading}
required
/>
</div>
<button
<button
type="submit"
className="admin-login-button"
disabled={isLocked}
style={isLocked ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
disabled={isLocked || isLoading}
style={(isLocked || isLoading) ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
>
<i className={isLocked ? "ri-lock-line" : "ri-login-box-line"}></i>
{isLocked ? "账户已锁定" : "登录"}
<i className={isLocked ? "ri-lock-line" : isLoading ? "ri-loader-4-line" : "ri-login-box-line"}></i>
{isLocked ? "账户已锁定" : isLoading ? "登录中..." : "登录"}
</button>
{isLocked && (
@@ -334,7 +430,7 @@ export default function Login() {
<i className="ri-information-line"></i>
</div>
)}
</Form>
</form>
<div className="back-to-oauth">
<button
+2 -2
View File
@@ -541,7 +541,7 @@ export default function ReviewDetails() {
if (result.success) {
toastService.success('评查结果已确认,文档审核状态已更新');
// 导航到文档列表页
navigate('/documents');
navigate('/documents/list');
} else {
console.error('确认评查结果失败:', result.error);
toastService.error(`确认评查结果失败: ${result.error || '未知错误'}`);
@@ -622,7 +622,7 @@ export default function ReviewDetails() {
if (loaderData.previousRoute === 'filesUpload') {
items.unshift({ title: "文件上传", to: "/files/upload" });
} else if (loaderData.previousRoute === 'documents') {
items.unshift({ title: "文档列表", to: "/documents" });
items.unshift({ title: "文档列表", to: "/documents/list" });
} else if (loaderData.previousRoute === 'rulesFiles') {
items.unshift({ title: "评查文件列表", to: "/rules-files" });
}
+51 -31
View File
@@ -63,17 +63,17 @@ export async function loader({ request }: LoaderFunctionArgs) {
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { userInfo, frontendJWT } = await getUserSession(request);
// 获取分页参数
const url = new URL(request.url);
const currentPage = parseInt(url.searchParams.get("page") || "1", 10);
const pageSize = parseInt(url.searchParams.get("pageSize") || "10", 10);
try {
// 获取文档类型列表
// 获取文档类型列表(服务端需要显式传递 token,客户端依赖 axios 拦截器)
const typesResponse = await getDocumentTypes({pageSize:500}, frontendJWT);
const documentTypes = typesResponse.data?.types || [];
// 返回初始空数据,客户端将根据 sessionStorage 中的 reviewType 加载实际数据
return Response.json({
files: [],
@@ -82,7 +82,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
currentPage,
pageSize,
userInfo, // 传递用户信息到客户端
frontendJWT,
initialLoad: true
});
} catch (error) {
@@ -93,7 +92,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
export default function RulesFiles() {
const navigate = useNavigate();
const { files: initialFiles, documentTypes: allDocumentTypes, totalCount: initialTotal, currentPage, pageSize, userInfo, frontendJWT, result, message } = useLoaderData<typeof loader>();
const { files: initialFiles, documentTypes: allDocumentTypes, totalCount: initialTotal, currentPage, pageSize, userInfo, result, message } = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const dateFrom = searchParams.get('dateFrom') || '';
const dateTo = searchParams.get('dateTo') || '';
@@ -141,12 +140,28 @@ export default function RulesFiles() {
}
}, [result, message]);
// 辅助函数:从 localStorage 获取用户ID
const getUserId = useCallback((): string | undefined => {
if (typeof window === 'undefined') return undefined;
const userInfoStr = localStorage.getItem('user_info');
if (!userInfoStr) return undefined;
try {
const userInfoData = JSON.parse(userInfoStr);
return userInfoData.user_id?.toString();
} catch (error) {
console.error('解析 localStorage 用户信息失败:', error);
return undefined;
}
}, []);
// 客户端数据请求
const fetchData = useCallback(async (params: Record<string, string>) => {
setIsLoading(true);
try {
// 构建搜索参数
// 构建搜索参数token 由 axios 拦截器自动从 localStorage 获取)
const searchParams: DocumentSearchParams = {
fileType: params.fileType || undefined,
reviewStatus: params.reviewStatus || undefined,
@@ -157,7 +172,7 @@ export default function RulesFiles() {
page: parseInt(params.page || "1", 10),
pageSize: parseInt(params.pageSize || "10", 10)
};
// 根据 reviewType 添加类型过滤
if (reviewType === 'contract') {
searchParams.fileType = 'contract';
@@ -165,21 +180,24 @@ export default function RulesFiles() {
// 在 API 层处理 type_id 为 2 或 3 的过滤
searchParams.fileType = 'record';
}
// 如果用户手动选择了文件类型,优先使用用户选择的
if (params.fileType) {
searchParams.fileType = params.fileType;
}
// 从loader data中获取用户ID
const userId = userInfo?.user_id?.toString();
// 获取文件列表
const filesResponse = await getReviewFiles({...searchParams, token: frontendJWT}, null, userId);
// 从 localStorage 获取用户ID(与 token 管理保持一致)
const userId = getUserId();
if (!userId) {
throw new Error('用户身份验证失败,无法获取评查文件列表');
}
// 获取文件列表(token 由 axios 拦截器自动添加)
const filesResponse = await getReviewFiles(searchParams, null, userId);
if (filesResponse.error) {
throw new Error(filesResponse.error);
}
setFiles(filesResponse.data?.files || []);
setTotalCount(filesResponse.data?.total || 0);
} catch (error) {
@@ -188,7 +206,7 @@ export default function RulesFiles() {
} finally {
setIsLoading(false);
}
}, [reviewType]);
}, [reviewType, getUserId]); // 使用 getUserId 辅助函数
// 在组件挂载时从 sessionStorage 获取 reviewType 并加载数据
useEffect(() => {
@@ -234,17 +252,19 @@ export default function RulesFiles() {
if (currentParams.fileType) {
apiSearchParams.fileType = currentParams.fileType;
}
// 设置加载状态
setIsLoading(true);
// 从loader data中获取用户ID
const userId = userInfo?.user_id?.toString();
// 添加 token 参数到 apiSearchParams
apiSearchParams.token = frontendJWT;
// 从 localStorage 获取用户ID
const userId = getUserId();
if (!userId) {
toastService.error('用户身份验证失败,无法获取评查文件列表');
setIsLoading(false);
return;
}
// 获取文件列表
// 获取文件列表token 由 axios 拦截器自动添加)
getReviewFiles(apiSearchParams, null, userId)
.then(filesResponse => {
if (filesResponse.error) {
@@ -331,14 +351,15 @@ export default function RulesFiles() {
// 检查audit_status是否为0,如果是则更新为2
if (auditStatus === 0 || auditStatus === null) {
try {
// 从loader data中获取用户ID
const userId = userInfo?.user_id?.toString();
// 从 localStorage 获取用户ID
const userId = getUserId();
if (!userId) {
toastService.error('用户身份验证失败');
return;
}
const response = await updateDocumentAuditStatus(fileId, 2, userId, frontendJWT);
// token 由 axios 拦截器自动添加
const response = await updateDocumentAuditStatus(fileId, 2, userId);
if (response.error) {
throw new Error(response.error);
}
@@ -348,7 +369,7 @@ export default function RulesFiles() {
return;
}
}
// 导航到评查详情页
// 在离开当前页前保存当前查询参数,返回时可恢复
if (typeof window !== 'undefined') {
@@ -520,8 +541,7 @@ export default function RulesFiles() {
attachmentFiles,
attachmentMergeMode,
true,
attachmentRemark || undefined,
frontendJWT as string | undefined
attachmentRemark || undefined
);
if (result.error) {
throw new Error(result.error);
@@ -89,42 +89,47 @@ function mapApiRuleToModel(apiRule: ApiRule): Rule {
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
// 从 URL 参数中提取查询条件
const params = {
page: parseInt(url.searchParams.get("page") || "1", 10),
pageSize: parseInt(url.searchParams.get("pageSize") || "10", 10)
};
try {
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
// 获取评查点类型列表,供前端筛选使
const typeResponse = await getRuleTypes(undefined, frontendJWT);
if (typeResponse.error) {
console.error('获取评查点类型失败:', typeResponse.error);
}
const ruleTypes = typeResponse.error ? [] : typeResponse.data;
// 返回初始空数据,客户端将根据 sessionStorage 中的 reviewType 加载实际数据
return Response.json({
rules: [],
totalCount: 0,
currentPage: params.page,
pageSize: params.pageSize,
ruleTypes,
initialLoad: true,
frontendJWT
}, {
headers: {
"Cache-Control": "max-age=60, s-maxage=180"
// 🔑 使用 handleServerAuth 包装,自动处理 token 过期
const { handleServerAuth } = await import("~/utils/server-auth-handler");
return handleServerAuth(async () => {
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
// 获取评查点类型列表,供前端筛选使用
const typeResponse = await getRuleTypes(undefined, frontendJWT);
if (typeResponse.error) {
console.error('获取评查点类型失败:', typeResponse.error);
}
});
const ruleTypes = typeResponse.error ? [] : typeResponse.data;
// 返回初始空数据,客户端将根据 sessionStorage 中的 reviewType 加载实际数据
return Response.json({
rules: [],
totalCount: 0,
currentPage: params.page,
pageSize: params.pageSize,
ruleTypes,
initialLoad: true,
frontendJWT
}, {
headers: {
"Cache-Control": "max-age=60, s-maxage=180"
}
});
}, url.pathname);
} catch (error) {
console.error('加载评查点列表失败:', error);
return Response.json({
@@ -135,31 +140,38 @@ export async function loader({ request }: LoaderFunctionArgs) {
}
export async function action({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const formData = await request.formData();
const _action = formData.get('_action');
const ruleId = formData.get('ruleId');
if (!ruleId) {
return Response.json({ result: false, message: "缺少评查点ID" }, { status: 400 });
}
try {
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
if (_action === 'delete') {
// 调用API删除评查点
// console.log(`删除评查点 ${ruleId}`);
const deleteResponse = await deleteRule(ruleId as string, frontendJWT);
if (deleteResponse.error) {
return Response.json({ result: false, message: deleteResponse.error }, { status: deleteResponse.status || 500 });
// 🔑 使用 handleServerAuth 包装,自动处理 token 过期
const { handleServerAuth } = await import("~/utils/server-auth-handler");
return handleServerAuth(async () => {
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
if (_action === 'delete') {
// 调用API删除评查点
const deleteResponse = await deleteRule(ruleId as string, frontendJWT);
if (deleteResponse.error) {
return Response.json({ result: false, message: deleteResponse.error }, { status: deleteResponse.status || 500 });
}
return Response.json({ result: true, message: "评查点删除成功" }, { status: 200 });
}
return Response.json({ result: true, message: "评查点删除成功" }, { status: 200 });
}
return Response.json({ result: false, message: "未知操作" }, { status: 400 });
}, url.pathname);
} catch (error) {
console.error('操作评查点失败:', error);
return Response.json({ result: false, message: error instanceof Error ? error.message : "操作失败" }, { status: 500 });
@@ -232,6 +244,11 @@ export default function RulesIndex() {
// 检查用户是否为开发者角色
const userRole = rootData?.userRole || 'common';
const isDeveloper = userRole === 'admin';
// 调试日志
// console.log("🔑 [Rules List] rootData:", rootData);
// console.log("🔑 [Rules List] 用户角色:", userRole);
// console.log("🔑 [Rules List] 是否为管理员:", isDeveloper);
// 在组件渲染时初始化状态
// useEffect(() => {
-572
View File
@@ -1,572 +0,0 @@
/**
* 文档预览与内容抽取模块
*
* 依赖包说明:
* 1. react-pdf - PDF文档预览
* 安装命令: npm install react-pdf
* 或: yarn add react-pdf
*
* 2. mammoth - Word文档转HTML预览
* 安装命令: npm install mammoth
* 或: yarn add mammoth
*
* 3. @remix-run/react, @remix-run/node - Remix框架组件
* 安装命令: npm install @remix-run/react @remix-run/node
* 或: yarn add @remix-run/react @remix-run/node
*
* 注意事项:
* - react-pdf需要pdfjs-dist作为依赖,安装react-pdf时会自动安装
* - 需要引入PDF.js worker文件,本代码通过CDN方式引入
* - 如需本地加载PDF.js worker文件,请安装pdfjs-dist并修改worker配置
*/
import { useState, useEffect, useRef } from "react";
import { useLoaderData } from "@remix-run/react";
import { Document, Page, pdfjs } from "react-pdf";
import type { LoaderFunctionArgs } from "@remix-run/node";
import mammoth from "mammoth";
/**
* 设置 pdfjs 工作线程
* 使用 CDN 上的 worker.js 文件处理 PDF 解析
*/
pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.js`;
/**
* 模拟后端返回的文档抽取内容数据
* 实际应用中应从API获取
*/
const mockExtractedContent = [
{ id: 1, text: "合同条款", page: 2, position: { start: 50, end: 60 } },
{ id: 2, text: "签署日期", page: 5, position: { start: 120, end: 130 } },
{ id: 3, text: "责任划分", page: 3, position: { start: 80, end: 90 } },
];
/**
* 文档抽取内容接口定义
*/
interface ExtractedContent {
id: number; // 内容唯一标识
text: string; // 抽取的文本内容
page: number; // 所在页码
position: { // 在页面中的位置信息
start: number;
end: number;
};
}
/**
* Loader 函数返回数据接口定义
*/
interface LoaderData {
fileUrl: string; // 当前文档URL
initialPage: number; // 初始页码
extractedContent: ExtractedContent[]; // 抽取内容数组
fileType: "pdf" | "docx"; // 文档类型
urls: Record<string, string>; // 可用文档URL列表
}
/**
* PDF文档加载成功回调接口
*/
interface DocumentLoadSuccess {
numPages: number; // 文档总页数
}
/**
* 根据URL判断文件类型
* @param url 文档URL
* @returns 文档类型:"pdf" 或 "docx"
*/
function getFileTypeFromUrl(url: string): "pdf" | "docx" {
const lowerCaseUrl = url.toLowerCase();
if (lowerCaseUrl.endsWith(".pdf")) {
return "pdf";
} else if (lowerCaseUrl.endsWith(".docx") || lowerCaseUrl.endsWith(".doc")) {
return "docx";
}
// 默认当作PDF处理
return "pdf";
}
/**
* Remix Loader 函数 - 请求处理和数据加载
*/
export const loader = async ({ request }: LoaderFunctionArgs) => {
// 从URL获取查询参数
const url = new URL(request.url);
const page = url.searchParams.get("page") || 1;
// 示例文档URLs集合
const urls = {
// 1. 原始文档URL - 可能有CORS限制
original: "https://dev-xc-enroll.oss-cn-guangzhou.aliyuncs.com/uploads/7840-230620112939.docx",
// 2. 公开示例文档 - 仍可能有CORS限制
public: "https://dev-xc-enroll.oss-cn-guangzhou.aliyuncs.com/uploads/7840-230620112939.docx",
// 3. 通过CORS代理 (示例)
proxy: "https://dev-xc-enroll.oss-cn-guangzhou.aliyuncs.com/uploads/7840-230620112939.docx",
// 4. 本地服务器上的文档 (假设已经部署)
local: "/uploads/sample.docx",
// 5. PDF示例
pdf: "http://nas.7bm.co:9000/docauditai/documents/%E5%90%88%E5%90%8C%E6%96%87%E6%A1%A3/2025/04%E6%9C%8816%E6%97%A5/%E7%AC%AC16%E5%8F%B7--%E9%94%80%E5%94%AE%E6%97%A0%E6%A0%87%E5%BF%97%E5%A4%96%E5%9B%BD%E5%8D%B7%E7%83%9F_10%E6%97%B626%E5%88%8632%E7%A7%92/%E7%AC%AC16%E5%8F%B7--%E9%94%80%E5%94%AE%E6%97%A0%E6%A0%87%E5%BF%97%E5%A4%96%E5%9B%BD%E5%8D%B7%E7%83%9F.pdf"
};
// 使用默认文档URL
const fileUrl = urls.pdf;
// 判断文件类型
const fileType = getFileTypeFromUrl(fileUrl);
// 返回加载的数据
return {
fileUrl,
initialPage: Number(page),
extractedContent: mockExtractedContent,
fileType,
urls
};
};
/**
* 文档预览组件
*/
export default function Documents() {
// 从loader获取数据
const { fileUrl, extractedContent, fileType, urls } = useLoaderData<LoaderData>();
// 状态管理
const [numPages, setNumPages] = useState<number | null>(null); // PDF总页数
const [scrollToPage, setScrollToPage] = useState<number | null>(null); // 滚动目标页码
const [docxLoading, setDocxLoading] = useState(false); // Word文档加载状态
const [loadError, setLoadError] = useState<string | null>(null); // 加载错误信息
const [debugInfo, setDebugInfo] = useState<string[]>([]); // 调试信息
const [docxHtml, setDocxHtml] = useState<string>(""); // 转换后的HTML内容
const [currentUrl, setCurrentUrl] = useState<string>(fileUrl); // 当前文档URL
// 引用
const docxContainerRef = useRef<HTMLDivElement>(null); // Word文档容器引用
/**
* 处理抽取内容点击事件 - 仅对PDF文档生效
* @param item 被点击的抽取内容项
*/
const handleContentClick = (item: ExtractedContent) => {
// 仅对PDF文档执行交互操作
if (fileType === "pdf") {
setScrollToPage(item.page);
// 对于PDF,滚动到指定页面
const pageElement = document.getElementById(`page-${item.page}`);
if (pageElement) {
pageElement.scrollIntoView({ behavior: 'smooth' });
}
}
// DOCX文档不执行任何交互操作
};
/**
* PDF文档加载成功回调函数
* @param param0 包含numPages的对象
*/
function onDocumentLoadSuccess({ numPages }: DocumentLoadSuccess) {
setNumPages(numPages);
// console.log("PDF加载成功,页数:", numPages);
}
/**
* 添加调试信息
* @param info 调试信息文本
*/
const addDebugInfo = (info: string) => {
// console.log(info);
setDebugInfo(prev => [...prev, `${new Date().toISOString().split('T')[1].split('.')[0]}: ${info}`]);
};
/**
* 切换文档URL
* @param urlKey URL键名
*/
const switchDocumentUrl = (urlKey: keyof typeof urls) => {
setCurrentUrl(urls[urlKey]);
setDebugInfo([]);
setLoadError(null);
setDocxLoading(false);
addDebugInfo(`切换到新的文档URL: ${urls[urlKey]}`);
};
/**
* Word文档处理逻辑
*/
useEffect(() => {
if (fileType === "docx" && docxContainerRef.current) {
setDocxLoading(true);
setDebugInfo([]); // 清空调试信息
addDebugInfo(`准备加载Word文档: ${currentUrl}`);
const loadDocx = async () => {
try {
// 1. 获取文档文件
addDebugInfo(`开始获取文件...`);
let response;
try {
response = await fetch(currentUrl, {
mode: 'cors',
credentials: 'omit',
headers: {
'Access-Control-Allow-Origin': '*'
}
});
addDebugInfo(`fetch请求状态: ${response.status} ${response.statusText}`);
} catch (fetchError) {
addDebugInfo(`fetch请求失败: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`);
throw new Error(`网络请求失败: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`);
}
// 检查响应状态
if (!response.ok) {
throw new Error(`文档无法访问,状态码: ${response.status}`);
}
addDebugInfo(`文档下载成功,状态码: ${response.status}`);
// 2. 将响应转换为ArrayBuffer
addDebugInfo(`开始读取响应内容为ArrayBuffer...`);
let buffer;
try {
buffer = await response.arrayBuffer();
addDebugInfo(`获取到文档数据,大小: ${buffer.byteLength} 字节`);
} catch (bufferError) {
addDebugInfo(`读取为ArrayBuffer失败: ${bufferError instanceof Error ? bufferError.message : String(bufferError)}`);
throw new Error(`转换文档内容失败: ${bufferError instanceof Error ? bufferError.message : String(bufferError)}`);
}
// 3. 使用mammoth.js将Word转换为HTML
addDebugInfo("使用mammoth开始转换文档为HTML...");
try {
// 自定义样式映射
const styleMap = `
p[style-name='Heading 1'] => h1:fresh
p[style-name='Heading 2'] => h2:fresh
p[style-name='Title'] => h1.title:fresh
p[style-name='Subtitle'] => h2.subtitle:fresh
table => table.docx-table
`;
// 转换选项
const options = {
arrayBuffer: buffer,
styleMap: styleMap,
includeDefaultStyleMap: true
};
// 执行转换
const result = await mammoth.convertToHtml(options);
// 检查转换警告
if (result.messages.length > 0) {
result.messages.forEach(message => {
addDebugInfo(`转换警告: [${message.type}] ${message.message}`);
});
}
addDebugInfo("文档转换成功,获取到HTML内容");
// 4. 为生成的HTML添加包装容器和样式
const enhancedHtml = `
<div class="document-container">
${result.value}
<div class="format-note">
<p>注意:部分复杂格式(如页眉页脚、复杂表格样式)可能无法完全显示。</p>
</div>
</div>
`;
// 更新状态
setDocxHtml(enhancedHtml);
setDocxLoading(false);
} catch (mammothError) {
addDebugInfo(`Mammoth转换失败: ${mammothError instanceof Error ? mammothError.message : String(mammothError)}`);
throw new Error(`Word转HTML失败: ${mammothError instanceof Error ? mammothError.message : String(mammothError)}`);
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
addDebugInfo(`文档处理错误: ${errorMessage}`);
setLoadError(`加载Word文档失败: ${errorMessage}`);
setDocxLoading(false);
}
};
loadDocx();
}
}, [currentUrl, fileType]);
/**
* 页面滚动逻辑
*/
useEffect(() => {
if (scrollToPage && fileType === "pdf") {
const pageElement = document.getElementById(`page-${scrollToPage}`);
if (pageElement) {
pageElement.scrollIntoView({ behavior: 'smooth' });
}
setScrollToPage(null);
}
}, [scrollToPage, fileType]);
/**
* 生成所有PDF页面的渲染数组
* @returns 页面组件数组
*/
const renderAllPages = () => {
if (!numPages) return null;
const pages = [];
for (let i = 1; i <= numPages; i++) {
pages.push(
<div key={i} id={`page-${i}`} className="mb-6">
<div className="text-center text-gray-500 text-sm mb-2"> {i} </div>
<Page
pageNumber={i}
renderTextLayer={false}
renderAnnotationLayer={false}
className="border border-gray-300 shadow-md"
/>
</div>
);
}
return pages;
};
return (
<div className="flex h-screen bg-gray-50">
{/* 文档展示区域 */}
<div className="flex-1 mr-6 p-4">
<div className="bg-white p-4 rounded-lg shadow-md h-full flex flex-col">
<h1 className="text-2xl font-bold mb-4"> ({fileType.toUpperCase()})</h1>
{/* 文档内容显示区域 */}
<div className="w-full flex-1 overflow-auto bg-gray-100 rounded-lg p-4">
{loadError ? (
<div className="text-red-500 flex flex-col items-center justify-center h-full">
<p className="mb-4">:</p>
<p>{loadError}</p>
<div className="mt-6 p-4 bg-gray-800 text-green-400 rounded text-xs max-w-xl overflow-auto max-h-96">
<p className="font-bold mb-2">:</p>
{debugInfo.map((info, index) => (
<div key={index} className="mb-1">{info}</div>
))}
</div>
<div className="mt-4">
<p className="text-black mb-2">:</p>
<div className="flex flex-wrap gap-2">
<button onClick={() => switchDocumentUrl('public')} className="px-3 py-1 bg-green-500 text-white rounded">
使
</button>
<button onClick={() => switchDocumentUrl('proxy')} className="px-3 py-1 bg-blue-500 text-white rounded">
使CORS代理
</button>
<button onClick={() => switchDocumentUrl('pdf')} className="px-3 py-1 bg-yellow-500 text-white rounded">
PDF
</button>
<a href={currentUrl} className="px-3 py-1 bg-gray-500 text-white rounded" download target="_blank" rel="noreferrer">
</a>
</div>
</div>
</div>
) : fileType === "pdf" ? (
/* PDF 文档渲染 */
<Document
file={currentUrl}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={(error) => {
console.error("PDF加载错误:", error);
setLoadError("PDF文档加载失败:" + (error.message || "未知错误"));
}}
className="flex flex-col items-center"
error={<div className="text-red-500">PDF文档加载失败</div>}
noData={<div></div>}
loading={<div className="text-center py-10">PDF加载中...</div>}
>
{renderAllPages()}
</Document>
) : (
/* Word 文档渲染 */
<>
{docxLoading ? (
/* 加载状态显示 */
<div className="flex flex-col items-center justify-center h-full">
<div className="mb-6">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
</div>
<p className="mb-4 text-lg">Word文档加载中...</p>
{debugInfo.length > 0 && (
<div className="mt-4 p-4 bg-gray-800 text-green-400 rounded text-xs max-w-xl overflow-auto max-h-72">
<p className="font-bold mb-2">:</p>
{debugInfo.map((info, index) => (
<div key={index} className="mb-1">{info}</div>
))}
</div>
)}
</div>
) : (
/* 本地渲染的Word文档 */
<div
ref={docxContainerRef}
className="w-full h-full"
style={{
height: '100%',
overflowY: 'auto',
padding: '20px',
backgroundColor: 'white'
}}
dangerouslySetInnerHTML={{ __html: docxHtml }}
/>
)}
</>
)}
</div>
</div>
</div>
{/* 抽取内容区域 - 始终显示,但DOCX模式下不交互 */}
<div className="w-80 bg-white p-4 rounded-lg shadow-md mr-4 my-4 overflow-auto">
<h2 className="text-xl font-semibold mb-4"></h2>
<ul className="space-y-3">
{extractedContent.map((item) => (
<button
key={item.id}
onClick={() => handleContentClick(item)}
className={`w-full text-left p-3 ${fileType === "pdf" ? "bg-gray-50 hover:bg-gray-100 cursor-pointer" : "bg-gray-100"} rounded-lg transition`}
disabled={fileType === "docx"}
aria-label={`查看内容: ${item.text}`}
>
<p className="text-sm font-medium">{item.text}</p>
<p className="text-xs text-gray-500">: {item.page}</p>
</button>
))}
</ul>
</div>
{/* 添加自定义样式 */}
<style dangerouslySetInnerHTML={{
__html: `
/* 高亮显示样式 */
.docx-highlight {
background-color: #ffff00;
outline: 2px solid orange;
position: relative;
}
/* 找到的内容高亮样式 */
.docx-content-found {
background-color: rgba(255, 230, 0, 0.3);
outline: 1px solid orange;
}
/* Mammoth.js生成的内容样式 */
.document-container {
font-family: "Microsoft YaHei", Arial, sans-serif;
line-height: 1.5;
color: #333;
max-width: 800px;
margin: 0 auto;
}
.document-container .format-note {
margin-top: 30px;
padding: 10px;
background-color: #f5f5f5;
border-left: 3px solid #ccc;
font-size: 12px;
color: #666;
}
.document-container h1 {
font-size: 24px;
margin-top: 24px;
margin-bottom: 16px;
font-weight: bold;
color: #222;
}
.document-container h1.title {
font-size: 28px;
text-align: center;
margin-bottom: 24px;
}
.document-container h2 {
font-size: 20px;
margin-top: 20px;
margin-bottom: 14px;
font-weight: bold;
color: #333;
}
.document-container h2.subtitle {
font-size: 18px;
text-align: center;
margin-bottom: 20px;
color: #555;
}
.document-container p {
margin-bottom: 16px;
text-align: justify;
overflow-wrap: break-word;
}
.document-container table {
border-collapse: collapse;
width: 100%;
margin-bottom: 16px;
}
.document-container table.docx-table {
border: 1px solid #ddd;
margin: 16px 0;
}
.document-container table.docx-table th,
.document-container table.docx-table td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
.document-container table.docx-table th {
background-color: #f2f2f2;
font-weight: bold;
}
.document-container ul, .document-container ol {
margin-left: 20px;
margin-bottom: 16px;
}
.document-container li {
margin-bottom: 5px;
}
.document-container img {
max-width: 100%;
height: auto;
margin: 10px 0;
}
.document-container span.underline {
text-decoration: underline;
}
.document-container span.strikethrough {
text-decoration: line-through;
}
/* 段落缩进 */
.document-container p:not(.no-indent) {
text-indent: 2em;
}
`
}} />
</div>
);
}