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

496 lines
17 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 { pdfjs } from 'react-pdf';
import { DOCUMENT_URL } from '~/api/axios-client';
import { CollaboraViewer, type CollaboraViewerHandle } from '~/components/collabora/CollaboraViewer';
import { requestPageInfo, customGotoPage } from '~/components/collabora/lib';
import { PdfPreview } from './previewComponents/PdfPreview';
// 设置worker路径为public目录下的worker文件
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
// 导入统一的ReviewPoint类型
import { type ReviewPoint } from './';
import { toastService } from '../ui/Toast';
// 定义文档内容类型
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;
}[];
template_contract_path?: string;
}
interface FilePreviewProps {
fileContent: FileContent;
reviewPoints?: ReviewPoint[]; // 设为可选
activeReviewPointResultId: string | null;
targetPage?: number; // 新增目标页码参数
charPositions?: Array<{ box: number[][], char: string, score: number }>; // 字符位置信息
isStructuredView?: boolean; // 是否显示结构化视图
userInfo?: {
sub: string;
nick_name: string;
}; // 用户信息(用于 Collabora
}
// export function FilePreview({ fileContent, reviewPoints, activeReviewPointResultId, targetPage }: FilePreviewProps) {
export function FilePreview({ fileContent, activeReviewPointResultId, targetPage, charPositions, isStructuredView = false, userInfo }: FilePreviewProps) {
// 获取文件类型
const real_path = fileContent.path || fileContent.template_contract_path || '';
const fileExtension = real_path.split('.').pop()?.toLowerCase();
const isDocx = fileExtension === 'docx';
const isPdf = fileExtension === 'pdf';
// 如果是PDF文件,直接使用PdfPreview组件
if (isPdf && real_path) {
// console.log('fileContent', fileContent)
// console.log('activeReviewPointResultId', activeReviewPointResultId)
const pageOffset = fileContent.ocrResult?.__meta?.page_offset || 0;
return (
<PdfPreview
filePath={real_path}
targetPage={targetPage}
charPositions={charPositions}
isStructuredView={isStructuredView}
activeReviewPointResultId={activeReviewPointResultId}
pageOffset={pageOffset}
/>
);
}
// DOCX 和其他文件类型继续使用原有逻辑
const contentRef = useRef<HTMLDivElement>(null);
const collaboraViewerRef = useRef<CollaboraViewerHandle>(null);
const [numPages, setNumPages] = useState<number | null>(null);
const [pageInputValue, setPageInputValue] = useState<string>('');
// DOCX 页数获取: 使用 requestPageInfo 方法
useEffect(() => {
if (!isDocx) return;
// console.log('[FilePreview] DOCX 文档加载,尝试获取页数');
let intervalCleared = false;
// 等待 CollaboraViewer 准备就绪
const checkInterval = setInterval(() => {
if (intervalCleared) return;
if (!collaboraViewerRef.current?.isReady) {
console.log('[FilePreview] 等待 Collabora 就绪...');
return;
}
// console.log('[FilePreview] Collabora 已就绪,尝试获取页数');
clearInterval(checkInterval);
intervalCleared = true;
const iframeWindow = collaboraViewerRef.current.getIframeWindow?.();
if (!iframeWindow) {
console.warn('[FilePreview] 无法获取 iframe window');
return;
}
// 使用 requestPageInfo 获取页数
requestPageInfo(iframeWindow)
.then((info) => {
setNumPages(info.totalPages);
})
.catch((error) => {
console.warn('[FilePreview] 获取 DOCX 页数失败:', error.message);
});
}, 500);
// 清理定时器
return () => {
clearInterval(checkInterval);
};
}, [isDocx]);
// 拖拽状态管理(仅用于 DOCX)
const [dragMode, setDragMode] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [dragCursor, setDragCursor] = useState('default');
const lastMousePosRef = useRef({ x: 0, y: 0 });
// 放大文档(仅用于 DOCX
const handleZoomIn = () => {
if (!collaboraViewerRef.current?.isReady) {
toastService.warning('文档尚未加载完成,请稍候...');
return;
}
collaboraViewerRef.current?.unoCommands.zoomIn();
};
// 缩小文档(仅用于 DOCX
const handleZoomOut = () => {
if (!collaboraViewerRef.current?.isReady) {
toastService.warning('文档尚未加载完成,请稍候...');
return;
}
collaboraViewerRef.current?.unoCommands.zoomOut();
};
// 切换拖拽模式
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) {
// 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);
};
// 处理页码跳转(仅用于 DOCX)
const handlePageJump = async () => {
if (!pageInputValue) return;
const targetPageNum = parseInt(pageInputValue, 10);
const iframeWindow = collaboraViewerRef.current?.getIframeWindow?.();
if (!iframeWindow) {
toastService.warning('文档尚未加载完成,请稍候...');
return;
}
if (targetPageNum > 0) {
try {
await customGotoPage(iframeWindow, targetPageNum);
setPageInputValue('');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '未知错误';
toastService.error(`跳转失败: ${errorMessage}`);
}
} else {
toastService.warning('请输入有效页码');
setPageInputValue('');
}
};
// 处理回车键跳转
const handlePageInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handlePageJump();
}
};
// 滚动到顶部(仅用于 DOCX
const handleScrollToTop = () => {
if (!collaboraViewerRef.current?.isReady) {
toastService.warning('文档尚未加载完成,请稍候...');
return;
}
collaboraViewerRef.current?.unoCommands.scrollToTop();
};
// 渲染文档内容
const renderDocumentContent = () => {
// 如果路径无效,显示错误信息
if (!real_path) {
if(!fileContent.template_contract_path){
return (
<div className="text-red-500 p-4">
<p></p>
</div>
);
}
return (
<div className="text-red-500 p-4">
<p></p>
</div>
);
}
// 根据文件类型选择不同的渲染方式
// 注意:PDF 文件已在组件开头使用 PdfPreview 组件提前返回
if (fileExtension === 'docx') {
// DOCX文件使用Collabora Online预览
return (
<CollaboraViewer
ref={collaboraViewerRef}
fileId={real_path}
mode="edit"
userId={userInfo?.sub || 'guest'}
userName={userInfo?.nick_name || '访客'}
/>
);
} else {
// 非PDF/DOCX文件显示不支持消息
return (
<div className="text-gray-500 p-4">
<p>{fileExtension}</p>
</div>
);
}
};
return (
<div className="file-preview">
<div className="file-preview-header px-2 text-xs sm:text-xs md:text-sm max-w-full flex items-center justify-between min-w-0">
<div className="flex items-center min-w-0 flex-shrink-0">
<i className={`${isStructuredView ? 'ri-file-list-line' : 'ri-file-text-line'} text-primary mr-2 flex-shrink-0`}></i>
<span className="font-medium text-primary truncate max-w-[120px]" title={isStructuredView ? '模板预览' : '文件预览'}>
{isStructuredView ? '模板预览' : '文件预览'}
</span>
</div>
<div className="file-preview-actions flex items-center ml-2 min-w-0 flex-1 justify-end overflow-hidden">
<button
className="ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5 flex-shrink-0"
onClick={handleScrollToTop}
title="返回顶部"
>
<i className="ri-arrow-up-double-line"></i>
<span className="ml-1 hidden sm:hidden md:hidden lg:hidden xl:inline truncate max-w-[60px]"></span>
</button>
<button
className="ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5 flex-shrink-0"
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 flex-shrink-0"
onClick={handleZoomOut}
title="缩小"
>
<i className="ri-zoom-out-line"></i>
</button>
{/* 页码跳转控件 */}
<div className="inline-flex items-center ml-2 flex-shrink-0">
<input
type="text"
className="ant-input ant-input-sm py-0 px-1 text-xs max-h-6 leading-5 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 hidden sm:hidden md:hidden lg:hidden xl:inline whitespace-nowrap">
/ {numPages}
</span>
)}
</div>
<button
className={`ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5 ml-2 flex-shrink-0 ${dragMode ? 'active bg-green-300' : ''}`}
title="切换拖拽模式"
aria-pressed={dragMode}
onClick={toggleDragMode}
>
<i className="ri-drag-move-line"></i>
<span className="ml-1 hidden sm:hidden md:hidden lg:hidden xl:inline truncate max-w-[80px]">
{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
}}
>
{renderDocumentContent()}
</button>
</div>
</div>
);
}