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

632 lines
24 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.
/**
* PDF 预览组件(设计稿 7c-简化C-极简 · 中栏实现)
*
* 与 PdfPreview.tsx 对齐的 props 签名,可直接替换用于测试。
* 变化:
* - 单页视口(非连续滚动)
* - 顶部工具栏:略缩图开关、上/下页、页码、缩放、当前高亮、全屏
* - 左侧 132px 略缩图面板(filtered / all 两种模式),真实 react-pdf 渲染
* - 状态徽标由 reviewPoints 聚合得到
*/
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import { toastService } from '~/components/ui/Toast';
import type { ReviewPoint } from '../ReviewPointsList';
import 'react-pdf/dist/esm/Page/TextLayer.css';
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
// ============================================================
// 类型
// ============================================================
type WorstStatus = 'fail' | 'warn' | 'pending' | 'pass';
interface PageAgg {
worst: WorstStatus;
count: number;
issues: number;
}
interface PdfPreviewProps {
filePath: string;
targetPage?: number;
charPositions?: Array<{ box: number[][]; char: string; score: number }>;
isStructuredView?: boolean;
activeReviewPointResultId?: string | null;
pageOffset?: number;
onNumPagesChange?: (numPages: number) => void;
onZoomChange?: (zoomLevel: number) => void;
// 新增(可选):用于派生略缩图徽标和当前高亮标签
reviewPoints?: ReviewPoint[];
}
// ============================================================
// ReviewPoint → 状态映射
// ============================================================
const STATUS_ORDER: Record<WorstStatus, number> = { fail: 0, warn: 1, pending: 2, pass: 3 };
const STATUS_BADGE: Record<WorstStatus, { cls: string; ic: string }> = {
fail: { cls: 'bg-red-500', ic: 'ri-close-circle-fill' },
warn: { cls: 'bg-amber-500', ic: 'ri-lightbulb-flash-fill' },
pending: { cls: 'bg-orange-500', ic: 'ri-question-fill' },
pass: { cls: 'bg-emerald-500', ic: 'ri-checkbox-circle-fill' },
};
function classifyReviewPoint(p: ReviewPoint): WorstStatus | 'skipped' {
const status = p.status;
if (status === 'notApplicable' || status === 'not_applicable') return 'skipped';
if (p.result === true || (p.result === undefined && status === 'success')) return 'pass';
if (p.result === false) {
if (status === 'error') return 'fail';
if (status === 'warning' || status === 'info') return 'warn';
}
if (status === 'success') return 'pass';
if (status === 'warning' || status === 'info') return 'warn';
if (status === 'error') return 'fail';
return 'pass';
}
/** 把 ReviewPoint 涉及的所有页号(字段级)抽出 */
function getPointPages(p: ReviewPoint): number[] {
const set = new Set<number>();
const addMaybe = (v: unknown) => {
const n = typeof v === 'string' ? parseInt(v, 10) : typeof v === 'number' ? v : NaN;
if (Number.isFinite(n) && n > 0) set.add(n);
};
if (p.contentPage) Object.values(p.contentPage).forEach(addMaybe);
if (p.content) Object.values(p.content).forEach(v => addMaybe(v?.page));
return [...set].sort((a, b) => a - b);
}
/** 当前规则在某页涉及的字段名列表 */
function getFieldsOnPage(p: ReviewPoint, page: number): string[] {
const fields: string[] = [];
const matches = (v: unknown) => {
const n = typeof v === 'string' ? parseInt(v, 10) : typeof v === 'number' ? v : NaN;
return n === page;
};
if (p.contentPage) {
Object.entries(p.contentPage).forEach(([k, v]) => {
if (matches(v)) fields.push(k);
});
}
if (!fields.length && p.content) {
Object.entries(p.content).forEach(([k, v]) => {
if (matches(v?.page)) fields.push(k);
});
}
return fields;
}
// ============================================================
// 组件
// ============================================================
export function PdfPreviewTest({
filePath,
targetPage,
charPositions,
isStructuredView = false,
activeReviewPointResultId,
pageOffset = 0,
onNumPagesChange,
onZoomChange,
reviewPoints,
}: PdfPreviewProps) {
// ---------- 基础状态 ----------
const [numPages, setNumPages] = useState<number | null>(null);
const [currentPage, setCurrentPage] = useState<number>(1);
const [zoomLevel, setZoomLevel] = useState(100);
const [loadError, setLoadError] = useState<string | null>(null);
// 略缩图面板
const [showThumbs, setShowThumbs] = useState(true);
const [thumbMode, setThumbMode] = useState<'filtered' | 'all'>('filtered');
// 坐标校准(保留主视口高亮逻辑)
const [coordinateScale, setCoordinateScale] = useState(0.83);
const [isScaleAutoCalculated, setIsScaleAutoCalculated] = useState(false);
// 页码跳转输入
const [pageInputValue, setPageInputValue] = useState<string>('');
const viewportRef = useRef<HTMLDivElement>(null);
const thumbsPanelRef = useRef<HTMLDivElement>(null);
// ---------- 派生数据 ----------
const fileUrl = useMemo(
() => `/api/pdf-proxy?path=${encodeURIComponent(filePath)}`,
[filePath],
);
const activePoint = useMemo<ReviewPoint | undefined>(
() => reviewPoints?.find(p => p.id === activeReviewPointResultId),
[reviewPoints, activeReviewPointResultId],
);
// 当前规则涉及的页(字段级,带 pageOffset
const rulePages = useMemo<number[]>(() => {
if (!activePoint) return [];
return getPointPages(activePoint).map(p => p + pageOffset);
}, [activePoint, pageOffset]);
// 每页状态聚合
const pageStatusMap = useMemo<Map<number, PageAgg>>(() => {
const m = new Map<number, PageAgg>();
if (!reviewPoints?.length) return m;
reviewPoints.forEach(p => {
const cls = classifyReviewPoint(p);
if (cls === 'skipped') return;
const pages = getPointPages(p).map(x => x + pageOffset);
pages.forEach(pg => {
const cur = m.get(pg) || { worst: 'pass', count: 0, issues: 0 };
if (STATUS_ORDER[cls] < STATUS_ORDER[cur.worst]) cur.worst = cls;
cur.count += 1;
if (cls !== 'pass') cur.issues += 1;
m.set(pg, cur);
});
});
return m;
}, [reviewPoints, pageOffset]);
// 当前高亮标签(工具栏右侧)
const highlightLabel = useMemo(() => {
if (!activePoint) return null;
const code = activePoint.pointCode || activePoint.id;
const name = activePoint.pointName || activePoint.title || '';
return `${code}${name ? ' · ' + name : ''}`;
}, [activePoint]);
// ---------- 通知上层 ----------
useEffect(() => {
if (numPages && onNumPagesChange) onNumPagesChange(numPages);
}, [numPages, onNumPagesChange]);
useEffect(() => {
if (onZoomChange) onZoomChange(zoomLevel);
}, [zoomLevel, onZoomChange]);
// ---------- targetPage 跳转 ----------
useEffect(() => {
if (targetPage && numPages) {
const next = Math.max(1, Math.min(numPages, targetPage + pageOffset));
setCurrentPage(next);
}
}, [targetPage, numPages, pageOffset, activeReviewPointResultId]);
// ---------- 切换规则:重置略缩图模式为 filtered ----------
useEffect(() => {
if (activeReviewPointResultId) setThumbMode('filtered');
}, [activeReviewPointResultId]);
// ---------- 文件路径变化:重置坐标自动计算 ----------
useEffect(() => {
setIsScaleAutoCalculated(false);
}, [filePath]);
// ---------- 略缩图滚动到当前页 ----------
useEffect(() => {
const host = thumbsPanelRef.current;
if (!host) return;
const el = host.querySelector<HTMLElement>(`[data-thumb-page="${currentPage}"]`);
if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}, [currentPage, thumbMode, showThumbs]);
// ---------- PDF 加载 ----------
const onDocumentLoadSuccess = useCallback(
({ numPages: n }: { numPages: number }) => {
setNumPages(n);
// 初始页:优先 targetPage,否则第 1 页
if (targetPage) {
setCurrentPage(Math.max(1, Math.min(n, targetPage + pageOffset)));
} else {
setCurrentPage(p => Math.max(1, Math.min(n, p)));
}
},
[targetPage, pageOffset],
);
const onDocumentLoadError = useCallback((error: Error) => {
console.error('PDF加载错误:', error);
setLoadError('PDF文档加载失败:' + (error.message || '未知错误'));
}, []);
// ---------- 主页面加载(自动校准坐标) ----------
const onMainPageLoadSuccess = useCallback(
(page: any) => {
if (isScaleAutoCalculated) return;
setTimeout(() => {
const pdfOriginalWidthPt = page.view?.[2] || page.originalWidth || page.width;
const canvas = viewportRef.current?.querySelector(
'.pdf-main-canvas .react-pdf__Page__canvas',
) as HTMLCanvasElement | null;
if (canvas && pdfOriginalWidthPt) {
const canvasDisplayWidth = canvas.offsetWidth;
const currentScale = zoomLevel / 100;
const autoScale = (canvasDisplayWidth / currentScale) / pdfOriginalWidthPt;
setCoordinateScale(autoScale);
setIsScaleAutoCalculated(true);
}
}, 200);
},
[isScaleAutoCalculated, zoomLevel],
);
// ---------- 翻页 / 缩放 ----------
const goPrev = () => setCurrentPage(p => Math.max(1, p - 1));
const goNext = () => setCurrentPage(p => Math.min(numPages || p, p + 1));
const zoomIn = () => setZoomLevel(z => Math.min(200, z + 10));
const zoomOut = () => setZoomLevel(z => Math.max(50, z - 10));
const jumpToHighlight = () => {
if (!activePoint || rulePages.length === 0) {
toastService.info('当前规则无关联页');
return;
}
const first = rulePages[0];
if (numPages && first >= 1 && first <= numPages) setCurrentPage(first);
};
const handlePageInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPageInputValue(e.target.value.replace(/\D/g, ''));
};
const handlePageJump = () => {
if (!pageInputValue || !numPages) return;
const n = parseInt(pageInputValue, 10);
if (n > 0 && n <= numPages) {
setCurrentPage(n);
setPageInputValue('');
} else {
toastService.warning(`请输入有效页码 (1-${numPages})`);
setPageInputValue('');
}
};
// ---------- 高亮矩形(对齐原 PdfPreview 的字符位置) ----------
const mainHighlight = useMemo(() => {
if (!charPositions?.length || !targetPage) {
console.log('[PdfPreviewTest] highlight skipped: no charPositions/targetPage', {
hasCharPositions: !!charPositions?.length,
targetPage,
});
return null;
}
if (currentPage !== targetPage + pageOffset) {
console.log('[PdfPreviewTest] highlight skipped: page mismatch', {
currentPage,
targetPage,
pageOffset,
expected: targetPage + pageOffset,
});
return null;
}
const scale = zoomLevel / 100;
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
const chars: string[] = [];
charPositions.forEach(cp => {
chars.push(cp.char);
cp.box.forEach(pt => {
const [x, y] = pt;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
});
});
return {
x: minX * coordinateScale * scale,
y: minY * coordinateScale * scale,
width: (maxX - minX) * coordinateScale * scale,
height: (maxY - minY) * coordinateScale * scale,
text: chars.join(''),
};
}, [charPositions, targetPage, currentPage, pageOffset, zoomLevel, coordinateScale]);
// ---------- 略缩图可见页列表 ----------
const effThumbMode: 'filtered' | 'all' =
thumbMode === 'filtered' && rulePages.length > 0 ? 'filtered' : 'all';
const thumbPages = useMemo<number[]>(() => {
if (!numPages) return [];
if (effThumbMode === 'filtered') return rulePages.filter(p => p >= 1 && p <= numPages);
return Array.from({ length: numPages }, (_, i) => i + 1);
}, [effThumbMode, rulePages, numPages]);
// ============================================================
// 渲染
// ============================================================
if (loadError) {
return (
<div className="w-full h-full grid place-items-center text-red-500 p-4">{loadError}</div>
);
}
return (
<Document
file={fileUrl}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={onDocumentLoadError}
className="flex flex-col min-h-0 w-full h-full"
loading={<div className="flex-1 grid place-items-center text-slate-400 text-sm">PDF </div>}
error={<div className="flex-1 grid place-items-center text-red-500">PDF </div>}
noData={<div className="flex-1 grid place-items-center text-slate-400"></div>}
>
<section
className="flex flex-col flex-1 min-h-0 w-full bg-slate-100 border border-slate-200 rounded"
>
{/* ═════ 顶部工具栏 ═════ */}
<div className="shrink-0 h-11 px-4 flex items-center justify-between bg-white border-b border-slate-200 text-[12.5px] text-slate-600">
<div className="flex items-center gap-2">
<button
onClick={() => setShowThumbs(s => !s)}
className={`w-5 h-7 grid place-items-center rounded hover:bg-slate-100 ${
showThumbs ? 'text-primary' : 'text-slate-400'
}`}
title="显示/隐藏页面缩略图"
>
<i className="ri-layout-masonry-line"></i>
</button>
<span className="mx-0 text-slate-300">|</span>
<button
onClick={goPrev}
disabled={currentPage <= 1}
className="w-5 h-7 grid place-items-center rounded hover:bg-slate-100 disabled:opacity-40 disabled:cursor-not-allowed"
title="上一页"
>
<i className="ri-arrow-left-s-line"></i>
</button>
<span className="font-mono tabular-nums">
<input
type="text"
value={pageInputValue !== '' ? pageInputValue : currentPage}
onChange={handlePageInputChange}
onFocus={e => e.currentTarget.select()}
onBlur={handlePageJump}
onKeyDown={e => {
if (e.key === 'Enter') handlePageJump();
}}
className="w-5 h-6 text-center bg-slate-50 border border-slate-200 rounded text-[12px] font-mono outline-none focus:border-primary"
/>
<span className="text-slate-400"> / {numPages ?? '-'}</span>
</span>
<button
onClick={goNext}
disabled={!numPages || currentPage >= numPages}
className="w-5 h-7 grid place-items-center rounded hover:bg-slate-100 disabled:opacity-40 disabled:cursor-not-allowed"
title="下一页"
>
<i className="ri-arrow-right-s-line"></i>
</button>
<span className="mx-0 text-slate-300">|</span>
<button
onClick={zoomOut}
disabled={zoomLevel <= 50}
className="w-5 h-7 grid place-items-center rounded hover:bg-slate-100 disabled:opacity-40"
title="缩小"
>
<i className="ri-zoom-out-line"></i>
</button>
<span className="font-mono min-w-[42px] text-center">{zoomLevel}%</span>
<button
onClick={zoomIn}
disabled={zoomLevel >= 200}
className="w-5 h-7 grid place-items-center rounded hover:bg-slate-100 disabled:opacity-40"
title="放大"
>
<i className="ri-zoom-in-line"></i>
</button>
</div>
<div className="flex items-center gap-2 text-[11.5px] min-w-0">
{highlightLabel && (
<>
<span className="text-slate-400 shrink-0">:</span>
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-50 text-amber-800 border border-amber-200 max-w-[220px]"
title={highlightLabel}
>
<i className="ri-focus-3-line shrink-0"></i>
<span className="truncate">{highlightLabel}</span>
</span>
<button
onClick={jumpToHighlight}
className="w-7 h-7 grid place-items-center rounded hover:bg-slate-100 shrink-0"
title="跳转到高亮页"
>
<i className="ri-crosshair-line"></i>
</button>
</>
)}
</div>
</div>
{/* ═════ 主体:略缩图 + 视口 ═════ */}
<div className="flex-1 min-h-0 flex min-w-0">
{/* ── 略缩图面板 ── */}
{showThumbs && (
<div className="w-[132px] shrink-0 bg-white border-r border-slate-200 flex flex-col min-h-0">
{/* 模式切换条 */}
<div className="shrink-0 px-2 py-2 border-b border-slate-100">
<div className="inline-flex rounded border border-slate-200 bg-slate-50 p-0.5 text-[10.5px] w-full">
<button
onClick={() => setThumbMode('filtered')}
disabled={rulePages.length === 0}
className={`flex-1 h-5 rounded font-medium ${
effThumbMode === 'filtered'
? 'bg-white shadow-sm text-primary'
: 'text-slate-500 hover:text-slate-700'
} ${rulePages.length === 0 ? 'opacity-40 cursor-not-allowed' : ''}`}
>
</button>
<button
onClick={() => setThumbMode('all')}
className={`flex-1 h-5 rounded font-medium ${
effThumbMode === 'all'
? 'bg-white shadow-sm text-primary'
: 'text-slate-500 hover:text-slate-700'
}`}
>
{numPages ?? ''}
</button>
</div>
</div>
{/* 略缩图列表 */}
<div
ref={thumbsPanelRef}
className="flex-1 overflow-y-auto py-2 px-2 space-y-2"
>
{numPages === null ? (
<div className="text-center text-[11px] text-slate-400 py-4"></div>
) : thumbPages.length === 0 ? (
<div className="text-center text-[11px] text-slate-400 py-8">
<i className="ri-forbid-2-line text-2xl text-slate-300"></i>
<div className="mt-1"></div>
</div>
) : (
thumbPages.map(p => {
const info = pageStatusMap.get(p);
const isCur = p === currentPage;
const isRulePage = rulePages.includes(p);
let badge: React.ReactNode = null;
if (info) {
const b = STATUS_BADGE[info.worst];
const num = info.issues > 0 ? info.issues : info.worst === 'pass' ? '' : info.count;
badge = (
<span
className={`absolute top-1 right-1 inline-flex items-center justify-center min-w-[14px] h-[14px] px-0.5 rounded-full text-[9px] font-semibold text-white ${b.cls} shadow ring-1 ring-white`}
>
{num ? num : <i className={`${b.ic} text-[9px]`}></i>}
</span>
);
}
const frameCls = isCur
? 'ring-2 ring-[#00684a] shadow-md'
: effThumbMode === 'all' && isRulePage
? 'ring-1 ring-[#00684a]/40 shadow-sm'
: 'border border-slate-200 group-hover:border-slate-400 shadow-sm';
let fieldsLabel: React.ReactNode = null;
if (effThumbMode === 'filtered' && activePoint) {
const fs = getFieldsOnPage(activePoint, p - pageOffset);
const txt = fs.length ? fs.join(' · ') : '规则锚定页';
fieldsLabel = (
<div
className="text-[10px] leading-tight text-slate-500 text-center mt-0.5 line-clamp-2"
title={txt}
>
{txt}
</div>
);
}
return (
<button
key={p}
data-thumb-page={p}
onClick={() => setCurrentPage(p)}
className="block w-full group"
title={`${p}`}
>
<div className={`relative rounded overflow-hidden bg-white transition ${frameCls}`}>
<div className="w-full bg-gradient-to-b from-white to-slate-50 overflow-hidden">
<Page
pageNumber={p}
width={112}
renderTextLayer={false}
renderAnnotationLayer={false}
loading={<div className="w-full h-[150px] bg-slate-50" />}
error={<div className="w-full h-[150px] bg-slate-50" />}
/>
</div>
{badge}
</div>
<div
className={`text-center text-[10.5px] mt-1 ${
isCur ? 'text-[#00684a] font-semibold' : 'text-slate-500 group-hover:text-slate-700'
}`}
>
{p}
</div>
{fieldsLabel}
</button>
);
})
)}
</div>
</div>
)}
{/* ── 视口(单页) ── */}
<div
ref={viewportRef}
className="flex-1 min-h-0 min-w-0 overflow-auto p-4"
style={{ display: 'flex', justifyContent: 'center', alignItems: 'flex-start' }}
>
<div
className="pdf-main-canvas"
style={{
position: 'relative',
display: 'inline-block',
textAlign: 'left',
flexShrink: 0,
}}
>
{numPages !== null && (
<>
<Page
pageNumber={Math.min(currentPage, numPages)}
scale={zoomLevel / 100}
devicePixelRatio={typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1}
renderTextLayer={false}
renderAnnotationLayer={false}
onLoadSuccess={onMainPageLoadSuccess}
className="border border-slate-200 shadow-lg bg-white"
loading={<div className="w-[600px] h-[800px] grid place-items-center text-slate-400"></div>}
/>
{mainHighlight && (
<svg
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 12,
}}
>
<rect
x={mainHighlight.x}
y={mainHighlight.y}
width={mainHighlight.width}
height={mainHighlight.height}
fill="#00AA00"
fillOpacity="0.1"
stroke="#00684a"
strokeWidth="0.5"
rx="2"
>
<title>{`高亮文本: ${mainHighlight.text}`}</title>
</rect>
</svg>
)}
</>
)}
</div>
</div>
</div>
</section>
</Document>
);
}