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

558 lines
21 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, forwardRef, useImperativeHandle, ChangeEvent } from 'react';
import { CollaboraViewer, type CollaboraViewerHandle } from '~/components/collabora/CollaboraViewer';
import { requestPageInfo, customGotoPage } from '~/components/collabora/lib';
import { PdfPreview } from './previewComponents/PdfPreview';
import { PdfPreviewTest } from './previewComponents/PdfPreviewTest';
import { toastService } from '../ui/Toast';
// 直接从ReviewPointsList导入类型,避免循环依赖
import { type ReviewPoint } from './ReviewPointsList';
// 定义文档内容类型
interface FileContent {
title: string;
contractNumber: string;
path: string;
ocrResult?: {
__meta?: {
page_offset?: number;
};
}; // 添加ocrResult属性
ocr_result?:{
__meta?: {
page_offset?: number;
};
},
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 }>; // 字符位置信息(仅用于PDF
highlightValue?: string; // 高亮文本值(用于DOCX
isStructuredView?: boolean; // 是否显示结构化视图
userInfo?: {
sub: string;
nick_name: string;
}; // 用户信息(用于 Collabora
aiSuggestionReplace?: {
searchText: string;
replaceText: string;
pageNumber: number;
silentReplace?: boolean; // 是否静默替换(不显示面板)
}; // AI建议替换参数
isTemplate?: boolean;
}
// 暴露给父组件的接口
export interface FilePreviewHandle {
collaboraViewerRef: React.RefObject<CollaboraViewerHandle>;
}
// export function FilePreview({ fileContent, reviewPoints, activeReviewPointResultId, targetPage }: FilePreviewProps) {
export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(function FilePreview({ fileContent, reviewPoints, activeReviewPointResultId, targetPage, charPositions, highlightValue, isStructuredView = false, userInfo, aiSuggestionReplace, isTemplate = false }, ref) {
// 获取文件类型
const real_path = fileContent.path || fileContent.template_contract_path || '';
const fileExtension = real_path.split('.').pop()?.toLowerCase();
// 是文档类型 并且 是模板文件
const isDocx = fileExtension === 'docx';
// 是模板文件 或 是pdf文件就用pdf渲染
const isPdf = fileExtension === 'pdf';
// ✅ 将所有hooks移到条件return之前,确保遵守React Hooks规则
// Refs
const contentRef = useRef<HTMLDivElement>(null);
const collaboraViewerRef = useRef<CollaboraViewerHandle>(null);
const prevTargetPageRef = useRef<number | undefined>(undefined);
// 暴露 collaboraViewerRef 给父组件
useImperativeHandle(ref, () => ({
collaboraViewerRef
}));
// States
const [numPages, setNumPages] = useState<number | null>(null);
const [pageInputValue, setPageInputValue] = useState<string>('');
const [isDocumentLoading, setIsDocumentLoading] = useState<boolean>(true); // 文档加载状态
const [isScrollingToTop, setIsScrollingToTop] = useState<boolean>(false); // 返回顶部loading状态
const [isClearingHighlights, setIsClearingHighlights] = useState<boolean>(false); // 清除高亮loading状态
// ✅ 将所有useEffect移到条件return之前
// 清除高亮:在组件卸载或文档路径变化时
useEffect(() => {
// 返回清理函数
return () => {
if (isDocx && collaboraViewerRef.current?.isReady) {
console.log('[FilePreview] 🔥 文档切换,调用 clearAllHighlights');
// 调用暴露的清除方法
collaboraViewerRef.current.clearAllHighlights().catch(error => {
console.error('[FilePreview] ✗ 清除高亮失败:', error);
});
}
};
}, [real_path, isDocx]); // 当文档路径变化时,清除旧文档的高亮
// 监听文档加载状态
useEffect(() => {
if (!isDocx) {
setIsDocumentLoading(false); // 非DOCX文件直接设为已加载
return;
}
// DOCX文件需要等待 Collabora 准备就绪
setIsDocumentLoading(true);
const checkInterval = setInterval(() => {
if (collaboraViewerRef.current?.isReady) {
setIsDocumentLoading(false);
clearInterval(checkInterval);
}
}, 200);
return () => {
clearInterval(checkInterval);
};
}, [isDocx, real_path]); // 当文档路径变化时重新检测
// DOCX 页数获取: 使用 requestPageInfo 方法
useEffect(() => {
if (!isDocx || isPdf) return; // PDF文件不需要执行
let intervalCleared = false;
// 等待 CollaboraViewer 准备就绪
const checkInterval = setInterval(() => {
if (intervalCleared) return;
if (!collaboraViewerRef.current?.isReady) {
// console.log('[FilePreview] 等待 Collabora 就绪...');
return;
}
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, isPdf]);
// 处理页面跳转
useEffect(() => {
if (isPdf) return; // PDF由PdfPreview处理
// 如果有目标页码,并且与上次相同,提示用户
if(targetPage && numPages && targetPage <= numPages && targetPage === prevTargetPageRef.current){
// toastService.success(`已跳转至目标页码`);
}
// 如果有目标页码,并且与上次不同或activeReviewPointId变化了,则执行跳转
if (targetPage && numPages && targetPage <= numPages) {
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' : ''}`;
const pageElement = document.getElementById(pageElementId);
if (pageElement) {
pageElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
console.warn(`未找到页面元素: ${pageElementId}`);
}
}
}, [targetPage, numPages, fileContent, activeReviewPointResultId, isStructuredView, isPdf]);
// 调试日志
// console.log('[FilePreview] 组件渲染', {
// real_path,
// fileExtension,
// isDocx,
// isPdf,
// hasPath: !!fileContent.path,
// hasTemplatePath: !!fileContent.template_contract_path
// });
// 如果是PDF文件,直接使用PdfPreview组件
if (isPdf && real_path) {
// console.log('[FilePreview] 渲染PDF预览', { real_path, targetPage, charPositions });
// console.log('[FilePreview] 渲染PDF预览', { fileContent });
const pageOffset = fileContent.ocrResult?.__meta?.page_offset || fileContent.ocr_result?.__meta?.page_offset || 0;
// console.log('pageOffset', pageOffset)
return (
<PdfPreviewTest
// <PdfPreview
filePath={real_path}
targetPage={targetPage}
charPositions={charPositions}
reviewPoints={reviewPoints}
isStructuredView={isStructuredView}
activeReviewPointResultId={activeReviewPointResultId}
pageOffset={pageOffset}
/>
);
}
// DOCX 和其他文件类型继续使用原有逻辑
// 获取评查点对应的样式类
// 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 && (!numPages || targetPageNum <= numPages)) {
if (targetPageNum > 0) {
try {
await customGotoPage(iframeWindow, targetPageNum);
setPageInputValue('');
// toastService.success(`已跳转至第 ${targetPageNum} 页`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '未知错误';
toastService.error(`跳转失败: ${errorMessage}`);
}
// } else if (numPages && targetPageNum > numPages) {
// toastService.warning(`页码不能超过总页数 ${numPages}`);
} else {
toastService.warning('请输入有效页码');
}
};
// 处理回车键跳转
const handlePageInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handlePageJump();
}
};
// 滚动到顶部(支持 PDF 和 DOCX)
const handleScrollToTop = async () => {
setIsScrollingToTop(true);
try {
if (isPdf) {
// PDF文件:滚动到第一个页面元素
const firstPage = document.querySelector('[data-page-number="1"]');
if (firstPage) {
firstPage.scrollIntoView({ behavior: 'smooth', block: 'start' });
// console.log('[FilePreview] PDF已返回顶部');
} else if (contentRef.current) {
// 如果找不到页面元素,则滚动容器到顶部
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' });
// console.log('[FilePreview] PDF容器已返回顶部');
}
} else if (isDocx) {
// DOCX文件:模板预览模式尝试多种滚动方式,编辑模式使用Collabora命令
if (isTemplate) {
// 模板预览模式:尝试多种滚动方式
console.log('[FilePreview] 尝试返回顶部...');
// 1. 尝试滚动 contentRef 容器
if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' });
console.log('[FilePreview] 已滚动 contentRef 容器');
}
// 2. 尝试滚动外层的 file-preview-isolation 容器
const isolationContainer = document.querySelector('.file-preview-isolation');
if (isolationContainer) {
isolationContainer.scrollTo({ top: 0, behavior: 'smooth' });
console.log('[FilePreview] 已滚动 isolation 容器');
}
// 3. 最后滚动整个窗口
window.scrollTo({ top: 0, behavior: 'smooth' });
console.log('[FilePreview] 已滚动窗口');
} else {
// 编辑模式:使用Collabora UNO命令
if (!collaboraViewerRef.current?.isReady) {
toastService.warning('文档尚未加载完成,请稍候...');
setIsScrollingToTop(false);
return;
}
await collaboraViewerRef.current?.unoCommands.scrollToTop();
console.log('[FilePreview] DOCX已返回顶部(UNO命令)');
}
}
} catch (error) {
console.error('[FilePreview] 返回顶部失败:', error);
toastService.error('返回顶部失败');
} finally {
// 延迟500ms后重置loading状态,给用户足够的视觉反馈
setTimeout(() => {
setIsScrollingToTop(false);
}, 500);
}
};
// 清除所有高亮(仅用于 DOCX)
const handleClearAllHighlights = async () => {
if (!collaboraViewerRef.current?.isReady) {
toastService.warning('文档尚未加载完成,请稍候...');
return;
}
setIsClearingHighlights(true);
try {
await collaboraViewerRef.current.clearAllHighlights();
console.log('[FilePreview] 已清除所有高亮');
toastService.success('已清除所有高亮');
} catch (error) {
console.error('[FilePreview] 清除高亮失败:', error);
toastService.error('清除高亮失败');
} finally {
// 延迟500ms后重置loading状态
setTimeout(() => {
setIsClearingHighlights(false);
}, 500);
}
};
// 渲染文档内容
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') {
// 使用 highlightValue 作为高亮文本(用户点击评查点时传递的实际文本值)
// 不再从 charPositions 提取,因为 charPositions 是 PDF 特有的坐标信息
const highlightText = highlightValue;
console.log('docx跳转的目标页',targetPage)
// DOCX文件使用Collabora Online预览
// 如果是模板预览,使用只读模式;否则使用编辑模式
return (
<CollaboraViewer
ref={collaboraViewerRef}
fileId={real_path}
mode={isTemplate ? "view" : "edit"}
// mode={"edit"}
userId={userInfo?.sub || 'unknown'}
userName={userInfo?.nick_name || ''}
targetPage={targetPage}
highlightText={highlightText}
aiSuggestionReplace={aiSuggestionReplace}
/>
);
} else {
// 非PDF/DOCX文件显示不支持消息
return (
<div className="text-gray-500 p-4">
<p>{fileExtension}</p>
</div>
);
}
};
return (
<div className="file-preview h-full flex flex-col">
<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 flex-shrink-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 gap-2">
{/* 返回顶部按钮 - 不在模板预览时显示 */}
{!isTemplate && (
<button
className={`flex items-center justify-center px-2 py-1 text-xs text-gray-700 bg-white border border-gray-300 rounded transition-colors duration-200 flex-shrink-0 outline-none ${
isScrollingToTop || isDocumentLoading
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-50 hover:border-primary hover:text-primary'
}`}
onClick={handleScrollToTop}
disabled={isScrollingToTop || isDocumentLoading}
title="返回顶部"
>
{isScrollingToTop ? (
<i className="ri-loader-4-line text-sm animate-spin"></i>
) : (
<i className="ri-arrow-up-double-line text-sm"></i>
)}
<span className="ml-1 hidden sm:hidden md:hidden lg:hidden xl:inline whitespace-nowrap">
{isScrollingToTop ? '返回中...' : '返回顶部'}
</span>
</button>
)}
{/* 清除高亮按钮 - 仅在DOCX文档时显示,且不是模板预览 */}
{isDocx && !isTemplate && (
<button
className={`flex items-center justify-center px-2 py-1 text-xs text-white bg-red-500 border border-red-500 rounded transition-colors duration-200 flex-shrink-0 outline-none ${
isClearingHighlights || isDocumentLoading
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-red-600 hover:border-red-600'
}`}
onClick={handleClearAllHighlights}
disabled={isClearingHighlights || isDocumentLoading}
title="清除所有高亮"
>
{isClearingHighlights ? (
<i className="ri-loader-4-line text-sm animate-spin"></i>
) : (
<i className="ri-eraser-line text-sm"></i>
)}
<span className="ml-1 hidden sm:hidden md:hidden lg:hidden xl:inline whitespace-nowrap">
{isClearingHighlights ? '清除中...' : '清除高亮'}
</span>
</button>
)}
{/* 页码跳转控件 - 不在模板预览时显示 */}
{!isTemplate && (
<div className="inline-flex items-center bg-white border border-gray-300 rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 flex-shrink-0 overflow-hidden">
<div className="flex items-center px-2 py-1">
<i className="ri-file-list-line text-sm text-gray-400 mr-1.5"></i>
<input
type="text"
className="w-10 h-5 px-1 text-xs text-center text-gray-700 bg-transparent border-0 outline-none focus:text-primary transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="页码"
value={pageInputValue}
onChange={handlePageInputChange}
onKeyDown={handlePageInputKeyDown}
disabled={isDocumentLoading}
/>
{/* {numPages && (
<span className="text-xs text-gray-400 mx-0.5">/</span>
)}
{numPages && (
<span className="text-xs text-gray-500 font-medium min-w-[1.5rem] text-center">
{numPages}
</span>
)} */}
</div>
<button
className="flex items-center justify-center h-7 px-2.5 text-white bg-primary hover:bg-primary-hover transition-colors duration-200 outline-none disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-primary border-l border-primary-hover/20"
onClick={handlePageJump}
disabled={!numPages || isDocumentLoading}
title="跳转"
>
<i className="ri-skip-forward-mini-line text-sm"></i>
</button>
</div>
)}
{/* 缩放提示 - 仅在DOCX文档时显示 */}
{isDocx && (
<div className="flex items-center px-2 py-1 text-xs text-gray-600 bg-gray-50 border border-gray-200 rounded flex-shrink-0">
<i className="ri-zoom-in-line text-sm mr-1"></i>
<span className="whitespace-nowrap">Ctrl+</span>
</div>
)}
</div>
</div>
<div
className="file-preview-content flex-1 overflow-auto"
ref={contentRef}
>
<div
className="pdf-interactive-container"
style={{
position: 'relative',
height: '100%',
width: '100%',
display: 'block',
textAlign: 'center',
padding: 0
}}
>
{renderDocumentContent()}
</div>
</div>
</div>
);
});