fix: 1.提交pdf预览的组件

This commit is contained in:
2025-11-25 20:52:43 +08:00
parent 83f8d80e12
commit 63857b3431
@@ -0,0 +1,472 @@
/**
* PDF预览组件
* 从 FilePreview.tsx 中抽取的 PDF 专用渲染组件
*/
import { useState, useEffect, useRef, ChangeEvent } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import { toastService } from '~/components/ui/Toast';
// 导入react-pdf的CSS样式(文本层和注释层必需)
import 'react-pdf/dist/esm/Page/TextLayer.css';
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
// 设置worker路径
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
/**
* 自定义样式
* 这些样式解决了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 PdfPreviewProps {
filePath: string; // PDF 文件路径
targetPage?: number; // 目标页码
charPositions?: Array<{ box: number[][], char: string, score: number }>; // 字符位置信息(用于高亮显示)
isStructuredView?: boolean; // 是否结构化视图
activeReviewPointResultId?: string | null; // 激活的评查点结果ID
pageOffset?: number; // 页码偏移量(用于调整 OCR 结果的页码)
onNumPagesChange?: (numPages: number) => void; // 页数变化回调
onZoomChange?: (zoomLevel: number) => void; // 缩放变化回调
}
export function PdfPreview({
filePath,
targetPage,
charPositions,
isStructuredView = false,
activeReviewPointResultId,
pageOffset = 0,
onNumPagesChange,
onZoomChange
}: PdfPreviewProps) {
// 调试日志
console.log('[PdfPreview] 组件渲染', {
filePath,
targetPage,
charPositions,
isStructuredView,
activeReviewPointResultId,
pageOffset
});
// ============ 状态管理 ============
const [numPages, setNumPages] = useState<number | null>(null);
const [zoomLevel, setZoomLevel] = useState(100);
const [loadError, setLoadError] = useState<string | null>(null);
const [pageInputValue, setPageInputValue] = useState<string>('');
// 坐标校准参数
const [coordinateScale, setCoordinateScale] = useState(0.83); // 坐标缩放系数(默认0.83
const [pdfOriginalWidth, setPdfOriginalWidth] = useState<number>(0); // PDF原始尺寸
const [isScaleAutoCalculated, setIsScaleAutoCalculated] = useState(false); // 是否已自动计算缩放
// 引用
const contentRef = useRef<HTMLDivElement>(null);
const prevTargetPageRef = useRef<number | undefined>(undefined);
// ============ 副作用 - 页数变化通知 ============
useEffect(() => {
if (numPages && onNumPagesChange) {
onNumPagesChange(numPages);
}
}, [numPages, onNumPagesChange]);
// ============ 副作用 - 缩放变化通知 ============
useEffect(() => {
if (onZoomChange) {
onZoomChange(zoomLevel);
}
}, [zoomLevel, onZoomChange]);
// ============ 副作用 - 页面跳转 ============
useEffect(() => {
if (targetPage && numPages && targetPage <= numPages) {
prevTargetPageRef.current = targetPage;
const newTargetPage = targetPage + pageOffset;
const pageElementId = `page-${newTargetPage}${isStructuredView ? '-structured' : ''}`;
const pageElement = document.getElementById(pageElementId);
if (pageElement) {
// 直接跳转,不使用滚动动画
pageElement.scrollIntoView({ behavior: 'auto', block: 'start' });
} else {
console.warn(`未找到页面元素: ${pageElementId}`);
}
}
}, [targetPage, numPages, pageOffset, activeReviewPointResultId, isStructuredView]);
// ============ 副作用 - 重置坐标缩放计算标志 ============
useEffect(() => {
// 只在文件路径变化时重置自动计算标志
// zoom变化时不应该重新计算coordinateScale,因为它是基于PDF原始尺寸的固定值
setIsScaleAutoCalculated(false);
}, [filePath]);
// ============ PDF 加载事件处理 ============
const onDocumentLoadSuccess = ({ numPages }: { numPages: number }) => {
setNumPages(numPages);
console.log("PDF加载成功,页数:", numPages);
};
const onDocumentLoadError = (error: Error) => {
console.error("PDF加载错误:", error);
setLoadError("PDF文档加载失败:" + (error.message || "未知错误"));
};
// ============ 缩放控制 ============
const handleZoomIn = () => {
if (zoomLevel < 200) {
setZoomLevel(prevZoom => prevZoom + 10);
}
};
const handleZoomOut = () => {
if (zoomLevel > 50) {
setZoomLevel(prevZoom => prevZoom - 10);
}
};
// ============ 页码跳转控制 ============
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: 'auto', block: 'start' });
setPageInputValue('');
}
} else {
toastService.warning(`请输入有效页码 (1-${numPages})`);
setPageInputValue('');
}
};
const handlePageInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handlePageJump();
}
};
// ============ 滚动到顶部 ============
const handleScrollToTop = () => {
if (contentRef.current) {
// 直接跳转到顶部,不使用滚动动画
contentRef.current.scrollTo({ top: 0, behavior: 'auto' });
}
};
// ============ 计算页面间距 ============
const calculatePageMargin = (zoomFactor: number) => {
// 固定间距,不随缩放变化,避免页面偏移
return 32; // 相当于 Tailwind 的 mb-8
};
// ============ 页面加载成功处理(用于自动计算坐标缩放) ============
const onPageLoadSuccess = (page: any) => {
// 只在第一页加载时计算一次坐标缩放系数
if (page.pageNumber === 1 && !isScaleAutoCalculated) {
setTimeout(() => {
// 获取PDF原始宽度(单位:点)
const pdfOriginalWidthPt = page.view?.[2] || page.originalWidth || page.width;
// 获取实际渲染的canvas元素
const canvas = document.querySelector('.react-pdf__Page__canvas') as HTMLCanvasElement;
if (canvas && pdfOriginalWidthPt) {
// 获取canvas在屏幕上的显示宽度
const canvasDisplayWidth = canvas.offsetWidth;
// 获取当前的zoom因子
const currentScale = zoomLevel / 100;
// 计算基于100% zoom的坐标缩放系数
// 需要除以当前的zoom因子,得到100%时的真实比例
const autoScale = (canvasDisplayWidth / currentScale) / pdfOriginalWidthPt;
console.log('自动计算坐标缩放:', {
pdfOriginalWidth: pdfOriginalWidthPt,
canvasDisplayWidth,
currentZoom: zoomLevel,
currentScale,
autoScale
});
setPdfOriginalWidth(pdfOriginalWidthPt);
setCoordinateScale(autoScale);
setIsScaleAutoCalculated(true);
}
}, 200);
}
};
// ============ 处理字符位置数据,转换为高亮矩形 ============
const processCharPositionsToHighlights = () => {
if (!charPositions || charPositions.length === 0 || !targetPage) {
return null;
}
const scale = zoomLevel / 100;
// 计算所有字符的边界框,形成一个连贯的高亮区域
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
// 收集所有字符文本
const allChars = charPositions.map(cp => cp.char).join('');
// 遍历所有字符,找出整体的边界
charPositions.forEach(charPos => {
const box = charPos.box;
// box是一个4个点的数组: [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
// 从所有点中找出最小和最大的坐标
box.forEach(point => {
const x = point[0];
const y = point[1];
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
});
});
// 计算连贯的高亮矩形,应用坐标缩放和zoom缩放
const x = minX * coordinateScale * scale;
const y = minY * coordinateScale * scale;
const width = (maxX - minX) * coordinateScale * scale;
const height = (maxY - minY) * coordinateScale * scale;
return {
x,
y,
width,
height,
text: allChars
};
};
// ============ 渲染所有PDF页面 ============
const renderAllPages = () => {
if (!numPages) return null;
const pages = [];
const zoomFactor = zoomLevel / 100;
const highlight = processCharPositionsToHighlights();
for (let i = 1; i <= numPages; i++) {
const pageContainerStyle = {
...styles.pageContainer,
marginBottom: `${calculatePageMargin(zoomFactor)}px`,
};
const pageId = isStructuredView ? `page-${i}-structured` : `page-${i}`;
// 计算调整后的目标页码(考虑pageOffset)
const adjustedTargetPage = targetPage ? targetPage + pageOffset : undefined;
// 判断当前页是否应该显示高亮
const shouldShowHighlight = adjustedTargetPage === i && highlight !== null;
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={{
position: 'relative',
display: 'inline-block',
margin: '0 auto',
}}
>
<Page
pageNumber={i}
scale={zoomLevel / 100}
devicePixelRatio={window.devicePixelRatio || 1}
renderTextLayer={false}
renderAnnotationLayer={false}
className="border border-gray-300 shadow-md"
onLoadSuccess={onPageLoadSuccess}
/>
{/* 坐标高亮层 - 渲染连贯的高亮区域 */}
{shouldShowHighlight && highlight && (
<svg
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 12
}}
>
<rect
x={highlight.x}
y={highlight.y}
width={highlight.width}
height={highlight.height}
fill="#00AA00"
fillOpacity="0.1"
stroke="#00684a"
strokeWidth="1"
rx="2"
>
<title>{`高亮文本: ${highlight.text}`}</title>
</rect>
</svg>
)}
</div>
</div>
);
}
return pages;
};
// ============ 主渲染 ============
return (
<div className="pdf-preview-component">
{/* 工具栏 */}
<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 gap-2">
<button
className="flex items-center justify-center px-2 py-1 text-xs text-gray-700 bg-white border border-gray-300 rounded hover:bg-gray-50 hover:border-primary hover:text-primary transition-colors duration-200 flex-shrink-0 outline-none"
onClick={handleScrollToTop}
title="返回顶部"
>
<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"></span>
</button>
<button
className="flex items-center justify-center w-7 h-7 text-gray-700 bg-white border border-gray-300 rounded hover:bg-gray-50 hover:border-primary hover:text-primary transition-colors duration-200 flex-shrink-0 outline-none disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-white disabled:hover:border-gray-300 disabled:hover:text-gray-700"
onClick={handleZoomIn}
title="放大"
disabled={zoomLevel >= 200}
>
<i className="ri-zoom-in-line text-sm"></i>
</button>
<button
className="flex items-center justify-center w-7 h-7 text-gray-700 bg-white border border-gray-300 rounded hover:bg-gray-50 hover:border-primary hover:text-primary transition-colors duration-200 flex-shrink-0 outline-none disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-white disabled:hover:border-gray-300 disabled:hover:text-gray-700"
onClick={handleZoomOut}
title="缩小"
disabled={zoomLevel <= 50}
>
<i className="ri-zoom-out-line text-sm"></i>
</button>
{/* 页码跳转控件 */}
<div className="inline-flex items-center flex-shrink-0 gap-1">
<input
type="text"
className="w-12 h-7 px-2 text-xs text-center text-gray-700 bg-white border border-gray-300 rounded outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-colors duration-200"
placeholder="页码"
value={pageInputValue}
onChange={handlePageInputChange}
onKeyDown={handlePageInputKeyDown}
/>
<button
className="flex items-center justify-center w-7 h-7 text-gray-700 bg-white border border-gray-300 rounded hover:bg-gray-50 hover:border-primary hover:text-primary transition-colors duration-200 outline-none disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-white disabled:hover:border-gray-300 disabled:hover:text-gray-700"
onClick={handlePageJump}
disabled={!numPages}
title="跳转到页面"
>
<i className="ri-arrow-right-line text-sm"></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>
<span className="text-xs text-gray-500 hidden sm:hidden md:hidden lg:hidden xl:inline whitespace-nowrap flex-shrink-0">
{zoomLevel}%
</span>
</div>
</div>
{/* PDF内容区域 */}
<div
className="file-preview-content"
ref={contentRef}
style={{
maxHeight: 'calc(100vh - 150px)',
overflowY: 'auto',
overflowX: 'auto',
}}
>
<div
className="pdf-interactive-container"
style={{
position: 'relative',
height: '100%',
width: '100%',
display: 'block',
textAlign: 'center',
padding: 0
}}
>
{loadError ? (
<div className="text-red-500 p-4">
<p>{loadError}</p>
</div>
) : (
<div
style={{
...styles.pdfContainer,
width: '100%',
overflow: 'visible'
}}
>
<Document
file={`/api/pdf-proxy?path=${encodeURIComponent(filePath)}`}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={onDocumentLoadError}
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>
)}
</div>
</div>
</div>
);
}