/** * 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, PdfBboxHighlight } 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 }>; bboxHighlight?: PdfBboxHighlight; isStructuredView?: boolean; activeReviewPointResultId?: string | null; pageOffset?: number; onNumPagesChange?: (numPages: number) => void; onZoomChange?: (zoomLevel: number) => void; // 新增(可选):用于派生略缩图徽标和当前高亮标签 reviewPoints?: ReviewPoint[]; } const THUMB_WIDTH = 112; const THUMB_ESTIMATED_HEIGHT = 210; const THUMB_OVERSCAN = 3; const MAIN_PAGE_MAX_DEVICE_PIXEL_RATIO = 1.5; // ============================================================ // ReviewPoint → 状态映射 // ============================================================ const STATUS_ORDER: Record = { fail: 0, warn: 1, pending: 2, pass: 3 }; function getContentItemPage(item: ReviewPoint['content'][string]): number | string | undefined { return typeof item === 'string' ? undefined : item?.page; } const STATUS_BADGE: Record = { 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(); 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(getContentItemPage(v))); 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(getContentItemPage(v))) fields.push(k); }); } return fields; } function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } function isValidQuad(value: unknown): value is [number, number, number, number] { return Array.isArray(value) && value.length === 4 && value.every(item => typeof item === 'number' && Number.isFinite(item)); } // ============================================================ // 组件 // ============================================================ export function PdfPreviewTest({ filePath, targetPage, charPositions, bboxHighlight, isStructuredView = false, activeReviewPointResultId, pageOffset = 0, onNumPagesChange, onZoomChange, reviewPoints, }: PdfPreviewProps) { const [numPages, setNumPages] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [zoomLevel, setZoomLevel] = useState(100); const [loadError, setLoadError] = useState(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(''); const [pageRenderTick, setPageRenderTick] = useState(0); const [thumbsScrollTop, setThumbsScrollTop] = useState(0); const [thumbsViewportHeight, setThumbsViewportHeight] = useState(0); const viewportRef = useRef(null); const thumbsPanelRef = useRef(null); const fileUrl = useMemo(() => `/api/pdf-proxy?path=${encodeURIComponent(filePath)}`, [filePath]); const activePoint = useMemo( () => reviewPoints?.find(p => p.id === activeReviewPointResultId), [reviewPoints, activeReviewPointResultId], ); const rulePages = useMemo(() => { if (!activePoint) return []; return getPointPages(activePoint).map(page => page + pageOffset); }, [activePoint, pageOffset]); const pageStatusMap = useMemo>(() => { const map = new Map(); if (!reviewPoints?.length) return map; reviewPoints.forEach(point => { const status = classifyReviewPoint(point); if (status === 'skipped') return; getPointPages(point).map(page => page + pageOffset).forEach(page => { const current = map.get(page) || { worst: 'pass', count: 0, issues: 0 }; if (STATUS_ORDER[status] < STATUS_ORDER[current.worst]) current.worst = status; current.count += 1; if (status !== 'pass') current.issues += 1; map.set(page, current); }); }); return map; }, [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]); const effThumbMode: 'filtered' | 'all' = thumbMode === 'filtered' && rulePages.length > 0 ? 'filtered' : 'all'; const thumbPages = useMemo(() => { if (!numPages) return []; if (effThumbMode === 'filtered') return rulePages.filter(page => page >= 1 && page <= numPages); return Array.from({ length: numPages }, (_, index) => index + 1); }, [effThumbMode, rulePages, numPages]); const rulePageSet = useMemo(() => new Set(rulePages), [rulePages]); const totalThumbHeight = thumbPages.length * THUMB_ESTIMATED_HEIGHT; const visibleThumbRange = useMemo(() => { if (!thumbPages.length) { return { start: 0, end: 0 }; } const viewportHeight = thumbsViewportHeight || THUMB_ESTIMATED_HEIGHT * 3; const start = Math.max(0, Math.floor(thumbsScrollTop / THUMB_ESTIMATED_HEIGHT) - THUMB_OVERSCAN); const visibleCount = Math.ceil(viewportHeight / THUMB_ESTIMATED_HEIGHT) + THUMB_OVERSCAN * 2; const end = Math.min(thumbPages.length, start + visibleCount); return { start, end }; }, [thumbPages.length, thumbsScrollTop, thumbsViewportHeight]); const visibleThumbItems = useMemo( () => thumbPages.slice(visibleThumbRange.start, visibleThumbRange.end), [thumbPages, visibleThumbRange.start, visibleThumbRange.end], ); const mainPageDevicePixelRatio = useMemo(() => { if (typeof window === 'undefined') return 1; return Math.min(window.devicePixelRatio || 1, MAIN_PAGE_MAX_DEVICE_PIXEL_RATIO); }, []); useEffect(() => { if (numPages && onNumPagesChange) onNumPagesChange(numPages); }, [numPages, onNumPagesChange]); useEffect(() => { if (onZoomChange) onZoomChange(zoomLevel); }, [zoomLevel, onZoomChange]); useEffect(() => { if (targetPage && numPages) { const next = Math.max(1, Math.min(numPages, targetPage + pageOffset)); setCurrentPage(next); } }, [targetPage, numPages, pageOffset, activeReviewPointResultId]); useEffect(() => { if (activeReviewPointResultId) setThumbMode('filtered'); }, [activeReviewPointResultId]); useEffect(() => { setIsScaleAutoCalculated(false); }, [filePath]); useEffect(() => { const host = thumbsPanelRef.current; if (!host) return; const currentIndex = thumbPages.indexOf(currentPage); if (currentIndex < 0) return; const itemTop = currentIndex * THUMB_ESTIMATED_HEIGHT; const itemBottom = itemTop + THUMB_ESTIMATED_HEIGHT; const viewportTop = host.scrollTop; const viewportBottom = viewportTop + host.clientHeight; if (itemTop < viewportTop) { host.scrollTo({ top: itemTop, behavior: 'smooth' }); return; } if (itemBottom > viewportBottom) { host.scrollTo({ top: Math.max(0, itemBottom - host.clientHeight), behavior: 'smooth', }); } }, [currentPage, thumbMode, showThumbs, thumbPages]); useEffect(() => { const host = thumbsPanelRef.current; if (!host) return; const updateViewport = () => { setThumbsViewportHeight(host.clientHeight); setThumbsScrollTop(host.scrollTop); }; updateViewport(); const handleScroll = () => { setThumbsScrollTop(host.scrollTop); }; host.addEventListener('scroll', handleScroll, { passive: true }); let resizeObserver: ResizeObserver | null = null; if (typeof ResizeObserver !== 'undefined') { resizeObserver = new ResizeObserver(() => { updateViewport(); }); resizeObserver.observe(host); } return () => { host.removeEventListener('scroll', handleScroll); resizeObserver?.disconnect(); }; }, [showThumbs, thumbMode, filePath]); const onDocumentLoadSuccess = useCallback( ({ numPages: loadedNumPages }: { numPages: number }) => { setNumPages(loadedNumPages); if (targetPage) { setCurrentPage(Math.max(1, Math.min(loadedNumPages, targetPage + pageOffset))); } else { setCurrentPage(page => Math.max(1, Math.min(loadedNumPages, page))); } }, [targetPage, pageOffset], ); const onDocumentLoadError = useCallback((error: Error) => { console.error('PDF加载错误:', error); setLoadError('PDF文档加载失败:' + (error.message || '未知错误')); }, []); const onMainPageLoadSuccess = useCallback( (page: any) => { setPageRenderTick(tick => tick + 1); 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(page => Math.max(1, page - 1)); const goNext = () => setCurrentPage(page => Math.min(numPages || page, page + 1)); const zoomIn = () => setZoomLevel(level => Math.min(200, level + 10)); const zoomOut = () => setZoomLevel(level => Math.max(50, level - 10)); const jumpToHighlight = () => { if (!activePoint || rulePages.length === 0) { toastService.info('当前规则无关联页'); return; } const firstPage = rulePages[0]; if (numPages && firstPage >= 1 && firstPage <= numPages) setCurrentPage(firstPage); }; const handlePageInputChange = (e: React.ChangeEvent) => { setPageInputValue(e.target.value.replace(/\D/g, '')); }; const handlePageJump = () => { if (!pageInputValue || !numPages) return; const nextPage = parseInt(pageInputValue, 10); if (nextPage > 0 && nextPage <= numPages) { setCurrentPage(nextPage); setPageInputValue(''); return; } toastService.warning(`请输入有效页码 (1-${numPages})`); setPageInputValue(''); }; const bboxRectHighlight = useMemo(() => { if (!bboxHighlight || !isValidQuad(bboxHighlight.bbox) || !isValidQuad(bboxHighlight.pageBox)) return null; const expectedPage = bboxHighlight.page ?? (typeof bboxHighlight.pageNum === 'number' ? bboxHighlight.pageNum + 1 : targetPage); if (!expectedPage || currentPage !== expectedPage + pageOffset) return null; const canvas = viewportRef.current?.querySelector('.pdf-main-canvas .react-pdf__Page__canvas') as HTMLCanvasElement | null; if (!canvas) return null; const [pageX0, pageY0, pageX1, pageY1] = bboxHighlight.pageBox; const [bboxLeft, bboxTop, bboxRight, bboxBottom] = bboxHighlight.bbox; const pageWidth = pageX1 - pageX0; const pageHeight = pageY1 - pageY0; if (pageWidth <= 0 || pageHeight <= 0) return null; const left = clamp(Math.min(bboxLeft, bboxRight), pageX0, pageX1); const right = clamp(Math.max(bboxLeft, bboxRight), pageX0, pageX1); const top = clamp(Math.min(bboxTop, bboxBottom), pageY0, pageY1); const bottom = clamp(Math.max(bboxTop, bboxBottom), pageY0, pageY1); if (right <= left || bottom <= top) return null; return { x: ((left - pageX0) / pageWidth) * canvas.offsetWidth, y: ((top - pageY0) / pageHeight) * canvas.offsetHeight, width: ((right - left) / pageWidth) * canvas.offsetWidth, height: ((bottom - top) / pageHeight) * canvas.offsetHeight, text: bboxHighlight.fieldKey, }; }, [bboxHighlight, targetPage, currentPage, pageOffset, zoomLevel, pageRenderTick]); const charRectHighlight = useMemo(() => { if (bboxRectHighlight || !charPositions?.length || !targetPage) return null; if (currentPage !== targetPage + pageOffset) return null; const scale = zoomLevel / 100; let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; const chars: string[] = []; charPositions.forEach(position => { chars.push(position.char); position.box.forEach(([x, y]) => { 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(''), }; }, [bboxRectHighlight, charPositions, targetPage, currentPage, pageOffset, zoomLevel, coordinateScale, pageRenderTick]); const mainHighlight = bboxRectHighlight || charRectHighlight; if (loadError) { return (
{loadError}
); } return ( PDF 加载中…} error={
PDF 文档加载失败
} noData={
无数据
} >
{/* ═════ 顶部工具栏 ═════ */}
| e.currentTarget.select()} onBlur={handlePageJump} onKeyDown={e => { if (e.key === 'Enter') handlePageJump(); }} className="w-6 h-6 text-center bg-slate-50 border border-slate-200 rounded text-[12px] font-mono outline-none focus:border-primary" /> / {numPages ?? '-'} | {zoomLevel}%
{highlightLabel && ( <> 当前高亮: {highlightLabel} )}
{/* ═════ 主体:略缩图 + 视口 ═════ */}
{/* ── 略缩图面板 ── */} {showThumbs && (
{/* 模式切换条 */}
{/* 略缩图列表 */}
{numPages === null ? (
加载中…
) : thumbPages.length === 0 ? (
此规则无关联页面
) : (
{visibleThumbItems.map((p, visibleIndex) => { const itemIndex = visibleThumbRange.start + visibleIndex; const info = pageStatusMap.get(p); const isCur = p === currentPage; const isRulePage = rulePageSet.has(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 = ( {num ? num : } ); } 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 = (
{txt}
); } return ( ); })}
)}
)} {/* ── 视口(单页) ── */}
{numPages !== null && ( <> 页面加载中…
} /> {mainHighlight && ( {`高亮文本: ${mainHighlight.text}`} )} )}
); }