/** * 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 = { fail: 0, warn: 1, pending: 2, pass: 3 }; 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(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(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 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], ); // 当前规则涉及的页(字段级,带 pageOffset) const rulePages = useMemo(() => { if (!activePoint) return []; return getPointPages(activePoint).map(p => p + pageOffset); }, [activePoint, pageOffset]); // 每页状态聚合 const pageStatusMap = useMemo>(() => { const m = new Map(); 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(`[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) => { 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(() => { 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 (
{loadError}
); } return ( PDF 加载中…} error={
PDF 文档加载失败
} noData={
无数据
} >
{/* ═════ 顶部工具栏 ═════ */}
| 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" /> / {numPages ?? '-'} | {zoomLevel}%
{highlightLabel && ( <> 当前高亮: {highlightLabel} )}
{/* ═════ 主体:略缩图 + 视口 ═════ */}
{/* ── 略缩图面板 ── */} {showThumbs && (
{/* 模式切换条 */}
{/* 略缩图列表 */}
{numPages === null ? (
加载中…
) : thumbPages.length === 0 ? (
此规则无关联页面
) : ( 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 = ( {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}`} )} )}
); }