Files
leaudit-platform-frontend/app/components/reviews/FilePreview.tsx
T

576 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 文件预览组件
* 显示文档内容和评查点高亮
*/
import { useState, useEffect, useRef, ChangeEvent } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
// 设置worker路径为public目录下的worker文件
// 使用已经下载的兼容版本 (pdfjs-dist v2.12.313)
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
// 导入统一的ReviewPoint类型
import { type ReviewPoint } from './';
import { toastService } from '../ui/Toast';
/**
* 自定义样式
* 这些样式解决了PDF页面在放大时互相重叠的问题
*/
const styles = {
pdfContainer: {
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
width: '100%',
position: 'relative' as const,
},
pageContainer: {
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
width: '100%',
position: 'relative' as const,
}
};
// 定义文档内容类型
interface FileContent {
title: string;
contractNumber: string;
path: string;
ocrResult?: {
__meta?: {
page_offset?: number;
};
}; // 添加ocrResult属性
parties: {
partyA: {
name: string;
address: string;
representative: string;
phone: string;
};
partyB: {
name: string;
address: string;
representative: string;
phone: string;
};
};
sections: {
title: string;
content: string;
}[];
}
interface FilePreviewProps {
fileContent: FileContent;
reviewPoints?: ReviewPoint[]; // 设为可选
activeReviewPointResultId: string | null;
targetPage?: number; // 新增目标页码参数
isStructuredView?: boolean; // 是否显示结构化视图
}
// export function FilePreview({ fileContent, reviewPoints, activeReviewPointResultId, targetPage }: FilePreviewProps) {
export function FilePreview({ fileContent, activeReviewPointResultId, targetPage, isStructuredView = false }: FilePreviewProps) {
const [zoomLevel, setZoomLevel] = useState(100);
// const [highlightsVisible, setHighlightsVisible] = useState(true);
const contentRef = useRef<HTMLDivElement>(null);
const [numPages, setNumPages] = useState<number | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [pageInputValue, setPageInputValue] = useState<string>('');
// 拖拽状态管理
const [dragMode, setDragMode] = useState(false); // 是否处于拖拽模式
const [isDragging, setIsDragging] = useState(false);
const [dragCursor, setDragCursor] = useState('default');
const lastMousePosRef = useRef({ x: 0, y: 0 });
// 放大文档
const handleZoomIn = () => {
if (zoomLevel < 200) {
setZoomLevel(prevZoom => prevZoom + 10);
}
};
// 缩小文档
const handleZoomOut = () => {
if (zoomLevel > 50) {
setZoomLevel(prevZoom => prevZoom - 10);
}
};
// 切换拖拽模式
const toggleDragMode = () => {
setDragMode(prev => !prev);
setDragCursor(prev => prev === 'default' ? 'grab' : 'default');
setIsDragging(false);
};
// 处理拖拽开始
const handleMouseDown = (e: React.MouseEvent<HTMLButtonElement>) => {
if (!dragMode || e.button !== 0) return; // 只在拖拽模式下响应左键点击
// 防止选中文本
e.preventDefault();
// 设置拖拽状态
setIsDragging(true);
setDragCursor('grabbing');
// 记录鼠标初始位置
lastMousePosRef.current = {
x: e.clientX,
y: e.clientY
};
};
// 处理拖拽过程
const handleMouseMove = (e: React.MouseEvent<HTMLButtonElement>) => {
if (!dragMode || !isDragging || !contentRef.current) return;
// 计算鼠标移动距离
const dx = e.clientX - lastMousePosRef.current.x;
const dy = e.clientY - lastMousePosRef.current.y;
// 更新容器滚动位置
contentRef.current.scrollLeft -= dx;
contentRef.current.scrollTop -= dy;
// 更新鼠标位置记录
lastMousePosRef.current = {
x: e.clientX,
y: e.clientY
};
};
// 处理拖拽结束
const handleMouseUp = () => {
if (!dragMode) return;
setIsDragging(false);
setDragCursor('grab');
};
// 监听鼠标离开窗口事件
useEffect(() => {
const handleMouseLeave = () => {
if (dragMode && isDragging) {
setIsDragging(false);
setDragCursor('grab');
}
};
document.addEventListener('mouseleave', handleMouseLeave);
document.addEventListener('mouseup', handleMouseUp as EventListener);
return () => {
document.removeEventListener('mouseleave', handleMouseLeave);
document.removeEventListener('mouseup', handleMouseUp as EventListener);
};
}, [isDragging, dragMode]);
// 处理页面跳转
const prevTargetPageRef = useRef<number | undefined>(undefined);
useEffect(() => {
// 调试信息:记录组件状态
// console.log(`FilePreview更新 - isStructuredView:${isStructuredView}, targetPage:${targetPage}, activeReviewPointResultId:${activeReviewPointResultId}, numPages:${numPages}`);
// 如果有目标页码,并且与上次相同,提示用户
if(targetPage && numPages && targetPage <= numPages && targetPage === prevTargetPageRef.current){
toastService.success(`已跳转至目标页码`);
}
// 如果有目标页码,并且与上次不同或activeReviewPointId变化了,则执行跳转
if (targetPage && numPages && targetPage <= numPages && (targetPage !== prevTargetPageRef.current || activeReviewPointResultId)) {
prevTargetPageRef.current = targetPage;
let newTargetPage = targetPage;
// 页码偏移量
try {
// 安全地访问ocrResult
if (fileContent.ocrResult && fileContent.ocrResult.__meta && fileContent.ocrResult.__meta.page_offset) {
// 可以根据需要使用page_offset调整目标页面
newTargetPage = targetPage + fileContent.ocrResult.__meta.page_offset;
}
} catch (error) {
console.error("访问ocrResult时出错:", error);
toastService.error("访问ocrResult时出错:" + (error instanceof Error ? error.message : '未知错误'));
}
const pageElementId = `page-${newTargetPage}${isStructuredView ? '-structured' : ''}`;
// console.log(`尝试跳转到元素ID: ${pageElementId}`);
const pageElement = document.getElementById(pageElementId);
if (pageElement) {
console.log(`跳转到第${newTargetPage}页,对应评查点结果ID: ${activeReviewPointResultId}`);
pageElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
console.warn(`未找到页面元素: ${pageElementId}`);
}
}
}, [targetPage, numPages, fileContent, activeReviewPointResultId, isStructuredView]);
// 获取评查点对应的样式类
// const getHighlightClass = (status: string) => {
// switch (status) {
// case 'warning':
// return 'warning';
// case 'error':
// return 'error';
// case 'success':
// return 'success';
// default:
// return 'warning';
// }
// };
// 处理页码输入变化
const handlePageInputChange = (e: ChangeEvent<HTMLInputElement>) => {
// 只允许输入数字
const value = e.target.value.replace(/\D/g, '');
setPageInputValue(value);
};
// 处理页码跳转
const handlePageJump = () => {
if (!pageInputValue || !numPages) return;
const targetPageNum = parseInt(pageInputValue, 10);
// 验证页码是否在有效范围内
if (targetPageNum > 0 && targetPageNum <= numPages) {
// 找到目标页面元素并滚动到该位置
const pageElement = document.getElementById(`page-${targetPageNum}`);
if (pageElement) {
pageElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
} else {
// 页码超出范围,显示错误信息或重置输入
toastService.warning(`请输入有效页码 (1-${numPages})`);
setPageInputValue('');
}
};
// 处理回车键跳转
const handlePageInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handlePageJump();
}
};
// PDF文档加载成功回调函数
function onDocumentLoadSuccess({ numPages }: { numPages: number }) {
setNumPages(numPages);
console.log("PDF加载成功,页数:", numPages);
}
// 计算页面在缩放后的实际间距
const calculatePageMargin = (zoomFactor: number) => {
// 基础间距为30px,随着缩放倍数线性增加
const baseMargin = 30;
// 页面缩放后,需要额外添加的间距 = (缩放倍数 - 1) * 页面高度
const additionalMargin = Math.max(0, (zoomFactor - 1) * 800); // 800是估计的页面高度
return baseMargin + additionalMargin;
};
// 滚动到顶部
const handleScrollToTop = () => {
if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' });
}
};
/**
* 渲染PDF文档的所有页面
*
* 功能描述:
* 1. 生成PDF所有页面的渲染数组,每个页面包含页码标识和实际页面内容
* 2. 处理页面缩放,通过CSS transform实现页面大小调整
* 3. 在每个页面上标记对应的评查点高亮区域
* 4. 处理评查点的激活状态,显示特殊的高亮效果
*
* @returns {JSX.Element[] | null} 返回所有页面组件的数组,如果没有页数信息则返回null
*/
const renderAllPages = () => {
// 如果还没有获取到PDF总页数,返回null
if (!numPages) return null;
// 用于存储所有页面组件的数组
const pages = [];
// 遍历每一页,生成对应的页面组件
for (let i = 1; i <= numPages; i++) {
// 计算当前缩放级别下的页面容器样式
const zoomFactor = zoomLevel / 100;
const pageContainerStyle = {
...styles.pageContainer,
marginBottom: `${calculatePageMargin(zoomFactor)}px`, // 动态计算页面间距
};
// 为结构化视图和普通视图创建不同的ID
const pageId = isStructuredView ? `page-${i}-structured` : `page-${i}`;
// 为每一页创建组件
pages.push(
<div key={i} id={pageId} style={pageContainerStyle}>
{/* 页码标识,显示在页面上方 */}
<div className="text-center text-gray-500 text-sm mb-2"> {i} </div>
{/* 页面容器,应用缩放变换,设置相对定位用于放置评查点高亮 */}
<div
className="page-wrapper"
style={{
transform: `scale(${zoomFactor})`, // 根据zoomLevel应用缩放
transformOrigin: 'top center', // 缩放原点设置为顶部中心
position: 'relative', // 相对定位,作为评查点高亮的定位参考
display: 'inline-block', // 内联块级元素,宽度由内容决定
margin: '0 auto', // 水平居中
}}
>
{/* 渲染PDF页面组件 */}
<Page
pageNumber={i} // 当前页码
renderTextLayer={true} // 启用文本层,使文本可选择
renderAnnotationLayer={true} // 启用注释层,显示PDF内置注释
className="border border-gray-300 shadow-md" // 添加边框和阴影样式
/>
{/* 渲染评查点高亮区域 */}
{/* {highlightsVisible && pageReviewPoints.map(point => {
// 判断当前评查点是否为激活状态(被选中)
const isActive = point.id === activeReviewPointId;
return (
// 评查点高亮区域
<div
key={point.id}
// 添加多个类名:基础高亮区域、PDF专用高亮、状态样式类、激活状态类
className={`highlight-area pdf-highlight ${getHighlightClass(point.status)} ${isActive ? 'highlight-focus' : ''}`}
// 设置唯一标识,用于滚动定位和DOM查询
data-review-id={point.id}
// 设置高亮区域的样式
style={{
position: 'absolute', // 绝对定位,相对于页面容器
zIndex: isActive ? 20 : 10, // 激活状态时提高层级,确保在最上层显示
boxShadow: isActive ? '0 0 0 2px yellow, 0 0 10px rgba(0,0,0,0.3)' : '', // 激活时添加特殊阴影效果
// 根据评查点的位置信息计算高亮区域位置,实际项目中需根据真实位置坐标计算
top: `${point.position?.index ? point.position.index * 20 : 20}px`,
left: '50px',
width: '300px',
height: '30px',
}}
/>
);
})} */}
</div>
</div>
);
}
// 返回所有页面组件数组
return pages;
};
// 渲染文档内容
const renderDocumentContent = () => {
// 如果路径无效,显示错误信息
if (!fileContent.path) {
return (
<div className="text-red-500 p-4">
<p></p>
</div>
);
}
// 获取文件扩展名
const fileExtension = fileContent.path.split('.').pop()?.toLowerCase();
// PDF内容渲染
const renderPdfContent = () => (
<div
style={{
...styles.pdfContainer,
// 当缩放大于100%时设置最小宽度,确保出现横向滚动条
minWidth: zoomLevel > 100 ? `${zoomLevel}%` : '100%',
overflow: 'visible'
}}
>
<Document
file={'http://172.18.0.100:9000/docauditai/'+fileContent.path}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={(error) => {
console.error("PDF加载错误:", error);
setLoadError("PDF文档加载失败:" + (error.message || "未知错误"));
}}
className="w-full"
error={<div className="text-red-500">PDF文档加载失败</div>}
noData={<div></div>}
loading={<div className="text-center py-10">PDF加载中...</div>}
>
{renderAllPages()}
</Document>
</div>
);
// 结构化数据渲染
const renderStructuredData = () => (
<div className="structured-view p-4 text-left mt-4 border-t border-gray-200">
<div className="text-xs font-medium mb-2"></div>
{fileContent.ocrResult ? (
<div className="overflow-auto max-h-[300px]">
<pre className="text-xs whitespace-pre-wrap bg-gray-50 p-3 rounded">
{JSON.stringify(fileContent.ocrResult, null, 2)}
</pre>
</div>
) : (
<div className="text-gray-500 p-4 text-center">
<p></p>
</div>
)}
</div>
);
// 根据文件类型选择不同的渲染方式
if (fileExtension === 'pdf') {
// 结构化视图模式:显示PDF和结构化数据
if (isStructuredView) {
return (
<div>
{renderPdfContent()}
{renderStructuredData()}
</div>
);
}
// 普通模式:仅显示PDF
return renderPdfContent();
} else {
// 非PDF文件显示不支持消息
return (
<div className="text-gray-500 p-4">
<p>{fileExtension}</p>
</div>
);
}
};
return (
<div className="file-preview">
<div className="file-preview-header py-2 px-4 text-xs sm:text-xs md:text-sm max-w-full text-overflow-ellipsis">
<div className="flex items-center">
<i className={`${isStructuredView ? 'ri-file-list-line' : 'ri-file-text-line'} text-primary mr-2`}></i>
<span className="font-medium text-primary">{isStructuredView ? '附件预览' : '文件预览'}</span>
</div>
<div className="file-preview-actions flex items-center">
<button
className="ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5"
onClick={handleScrollToTop}
title="返回顶部"
>
<i className="ri-arrow-up-double-line"></i>
<span className="ml-1 sm:inline md:inline lg:hidden xl:inline"></span>
</button>
<button
className="ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5"
onClick={handleZoomIn}
title="放大"
>
<i className="ri-zoom-in-line"></i>
</button>
<button
className="ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5"
onClick={handleZoomOut}
title="缩小"
>
<i className="ri-zoom-out-line"></i>
</button>
{/* 页码跳转控件 */}
<div className="inline-flex items-center ml-2">
<input
type="text"
className="ant-input ant-input-sm py-0 px-1 text-xs max-h-6 leading-5 max-w-[2.5rem] text-center
focus:outline-none focus:ring-1 focus:ring-green-900"
placeholder="页码"
value={pageInputValue}
onChange={handlePageInputChange}
onKeyDown={handlePageInputKeyDown}
/>
<button
className="ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5 ml-1"
onClick={handlePageJump}
disabled={!numPages}
title="跳转到页面"
>
<i className="ri-arrow-right-line"></i>
</button>
{numPages && <span className="ml-1 text-xs text-gray-500 sm:inline md:inline lg:hidden xl:inline">/ {numPages}</span>}
</div>
<span className="ml-2 text-xs text-gray-500 inline-block sm:inline md:inline lg:hidden xl:inline">{"比例:"+zoomLevel+"%"}</span>
<button
className={`ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5 ml-2 ${dragMode ? 'active bg-green-300' : ''}`}
title="切换拖拽模式"
aria-pressed={dragMode}
onClick={toggleDragMode}
>
<i className="ri-drag-move-line"></i>
<span className="ml-1 sm:inline md:inline lg:hidden xl:inline">{dragMode ? '(已激活)' : ''}</span>
</button>
</div>
</div>
<div
className="file-preview-content"
ref={contentRef}
style={{
maxHeight: 'calc(100vh - 150px)',
overflowY: 'auto',
overflowX: 'auto',
cursor: dragCursor,
userSelect: dragMode ? 'none' : 'auto', // 拖拽模式下防止文本被选中
}}
>
<button
className="pdf-interactive-container"
aria-label="PDF文档查看区域"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onKeyDown={(e) => {
// 添加键盘导航支持
const scrollAmount = 50;
if (e.key === 'ArrowUp') {
if (contentRef.current) contentRef.current.scrollTop -= scrollAmount;
e.preventDefault();
} else if (e.key === 'ArrowDown') {
if (contentRef.current) contentRef.current.scrollTop += scrollAmount;
e.preventDefault();
} else if (e.key === 'ArrowLeft') {
if (contentRef.current) contentRef.current.scrollLeft -= scrollAmount;
e.preventDefault();
} else if (e.key === 'ArrowRight') {
if (contentRef.current) contentRef.current.scrollLeft += scrollAmount;
e.preventDefault();
}
}}
style={{
position: 'relative',
height: '100%',
width: '100%',
display: 'block',
border: 'none',
background: 'transparent',
textAlign: 'center',
padding: 0
}}
>
{loadError ? (
<div className="text-red-500 p-4">
<p>{loadError}</p>
</div>
) : (
renderDocumentContent()
)}
</button>
</div>
</div>
);
}