feat: 1. 添加axios全局路由拦截进行自动添加请求jwt。 2.重新整理路由表。 3. 文档列表新增版本差异对比。 4.菜单路由可访问列表通过对接接口返回,添加全局路由检测。
5. 修改统一认证登录和管理员登录是通过接口形式进行,存储返回的accessToken。 6. 修改交叉评查的部分样式
This commit is contained in:
+34
-14
@@ -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
@@ -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、JWT(user_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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
// );
|
||||
// }
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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(() => {
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user