/** * ComparePreview - Document Comparison Preview Component * * Features: * - Compare two documents using Monaco Editor * - Support PDF and Word (.docx) files * - Automatic text extraction and line-by-line comparison * - Navigation between differences * * Props: * - doc1Path: Original document path * - doc2Path: Comparison document path */ import React, { useState, useRef, useEffect } from "react"; import { DiffEditor, loader } from "@monaco-editor/react"; import type { editor } from "monaco-editor"; import { pdfjs } from 'react-pdf'; import mammoth from 'mammoth'; import { toastService } from '~/components/ui/Toast'; import { DOCUMENT_URL } from '~/config/api-config'; // Setup PDF.js worker pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.js'; // 配置 Monaco Editor 使用本地资源(避免 CDN 加载超时) // Monaco Editor 资源已通过 npm run copy-monaco 复制到 public/monaco-editor if (typeof window !== 'undefined') { console.log('[Monaco] 使用本地资源加载'); loader.config({ paths: { vs: '/monaco-editor/vs' } }); // 添加加载超时监控和错误处理 const initTimeout = setTimeout(() => { console.error('[Monaco] 加载超时(30秒)'); toastService.error('代码编辑器加载超时,请刷新页面重试'); }, 30000); loader.init().then(() => { clearTimeout(initTimeout); console.log('[Monaco] ✅ 加载成功'); }).catch((error: Error) => { clearTimeout(initTimeout); console.error('[Monaco] ❌ 加载失败:', error); toastService.error(`代码编辑器加载失败: ${error.message}`); }); } // Document type enum type DocumentType = 'pdf' | 'docx' | 'unknown'; // PDF type enum type PdfType = 'text' | 'scanned' | 'unknown'; // PDF info interface interface PdfInfo { type: PdfType; numPages: number; textLength: number; confidence: number; } // Document info interface interface DocumentInfo { fileType: DocumentType; pdfType?: PdfType; numPages?: number; textLength: number; confidence: number; } // Component Props interface interface ComparePreviewProps { doc1Path: string; doc2Path: string; } export function ComparePreview({ doc1Path, doc2Path }: ComparePreviewProps): JSX.Element { // 如果没有模板合同路径,直接返回提示 if (!doc2Path || doc2Path.trim() === '') { return (

无法进行对比

该文档类型暂未上传模板合同,无法进行对比分析。

提示:请先在文档类型管理中为该类型上传模板合同,然后重新加载此页面。
); } const [originalText, setOriginalText] = useState(''); const [modifiedText, setModifiedText] = useState(''); const diffEditorRef = useRef(null); const [diffCount, setDiffCount] = useState(0); const [currentDiff, setCurrentDiff] = useState(0); const [doc1Info, setDoc1Info] = useState(null); const [doc2Info, setDoc2Info] = useState(null); const [isLoadingDoc1, setIsLoadingDoc1] = useState(false); const [isLoadingDoc2, setIsLoadingDoc2] = useState(false); // Log initial props // console.log('[ComparePreview] Component initialized with paths:', { doc1Path, doc2Path }); // Detect file type based on file path const detectFileType = (filePath: string): DocumentType => { const lowerPath = filePath.toLowerCase(); if (lowerPath.endsWith('.pdf')) return 'pdf'; if (lowerPath.endsWith('.docx') || lowerPath.endsWith('.doc')) return 'docx'; return 'unknown'; }; // PDF type detection function const detectPdfType = async (pdfUrl: string): Promise => { const loadingTask = pdfjs.getDocument(pdfUrl); const pdf = await loadingTask.promise; let totalTextLength = 0; const pagesToCheck = Math.min(pdf.numPages, 3); for (let i = 1; i <= pagesToCheck; i++) { const page = await pdf.getPage(i); const textContent = await page.getTextContent(); const pageText = textContent.items .map((item: any) => item.str) .join(''); totalTextLength += pageText.length; } const avgTextPerPage = totalTextLength / pagesToCheck; const confidence = Math.min(avgTextPerPage / 500, 1); let type: PdfType; if (avgTextPerPage > 100) { type = 'text'; } else if (avgTextPerPage > 10) { type = 'scanned'; } else { type = 'unknown'; } return { type, numPages: pdf.numPages, textLength: totalTextLength, confidence }; }; // PDF text extraction function const extractTextFromPdf = async (pdfUrl: string): Promise => { const loadingTask = pdfjs.getDocument(pdfUrl); const pdf = await loadingTask.promise; let fullText = ''; for (let i = 1; i <= pdf.numPages; i++) { const page = await pdf.getPage(i); const textContent = await page.getTextContent(); const pageText = textContent.items .map((item: any) => item.str) .join(' '); fullText += `\n========== Page ${i} ==========\n${pageText}\n`; } return fullText; }; // Word document text extraction function const extractTextFromWord = async (docUrl: string): Promise => { const response = await fetch(docUrl); if (!response.ok) { throw new Error(`Cannot load document: ${response.statusText}`); } const arrayBuffer = await response.arrayBuffer(); const textResult = await mammoth.extractRawText({ arrayBuffer }); return textResult.value; }; // Load document and extract text (supports PDF and Word) const loadDocumentAndExtractText = async ( docPath: string, setDocInfo: (info: DocumentInfo | null) => void, setLoading: (loading: boolean) => void, setTextContent: (text: string) => void ) => { try { setLoading(true); const docUrl = docPath.startsWith('http') ? docPath : `${DOCUMENT_URL}${docPath}`; // console.log('[ComparePreview] Loading document:', docUrl); const fileType = detectFileType(docPath); if (fileType === 'pdf') { const pdfInfo = await detectPdfType(docUrl); const text = await extractTextFromPdf(docUrl); const docInfo: DocumentInfo = { fileType: 'pdf', pdfType: pdfInfo.type, numPages: pdfInfo.numPages, textLength: pdfInfo.textLength, confidence: pdfInfo.confidence }; setDocInfo(docInfo); setTextContent(text); // console.log('[ComparePreview] PDF text extracted:', { // path: docPath, // textLength: text.length, // firstChars: text.substring(0, 100) // }); if (pdfInfo.type === 'text') { toastService.success(`PDF加载成功!共 ${pdfInfo.numPages} 页,提取了 ${pdfInfo.textLength} 个字符`); } else if (pdfInfo.type === 'scanned') { toastService.warning('检测到扫描版PDF,文本提取质量可能较低'); } else { toastService.error('无法识别PDF类型,可能是图片PDF'); } } else if (fileType === 'docx') { const text = await extractTextFromWord(docUrl); const docInfo: DocumentInfo = { fileType: 'docx', textLength: text.length, confidence: 1.0 }; setDocInfo(docInfo); setTextContent(text); // console.log('[ComparePreview] Word text extracted:', { // path: docPath, // textLength: text.length, // firstChars: text.substring(0, 100) // }); toastService.success(`Word文档加载成功!提取了 ${text.length} 个字符`); } else { toastService.error('不支持的文件类型'); setTextContent(''); } } catch (error) { console.error('Document loading failed:', error); toastService.error(`文档加载失败: ${error instanceof Error ? error.message : '未知错误'}`); setDocInfo(null); setTextContent(''); } finally { setLoading(false); } }; // Monaco Editor mount callback const handleEditorDidMount = (editor: editor.IStandaloneDiffEditor) => { diffEditorRef.current = editor; // console.log('[ComparePreview] Editor mounted, checking differences...', { // originalTextLength: originalText.length, // modifiedTextLength: modifiedText.length // }); // Polling function to get diff results // Monaco Editor's diff calculation is asynchronous and may return null initially let retryCount = 0; const maxRetries = 20; // Maximum 20 attempts const retryInterval = 150; // Check every 150ms const pollForDiffChanges = () => { retryCount++; const lineChanges = editor.getLineChanges(); // console.log(`[ComparePreview] Polling attempt ${retryCount}:`, { // lineChanges: lineChanges ? `${lineChanges.length} changes` : 'null', // hasLineChanges: lineChanges !== null // }); // If we got the diff results if (lineChanges !== null) { setDiffCount(lineChanges.length); // console.log(`[ComparePreview] ✅ Successfully got ${lineChanges.length} differences on attempt ${retryCount}`); // Verify the result makes sense if (lineChanges.length === 0 && originalText.length > 0 && modifiedText.length > 0) { const textsAreIdentical = originalText === modifiedText; // console.log('[ComparePreview] Texts are identical?', textsAreIdentical); if (textsAreIdentical) { // console.log('[ComparePreview] ℹ️ Documents are identical - 0 differences is correct'); } else { console.warn('[ComparePreview] ⚠️ Documents differ but Monaco shows 0 differences'); // console.log('[ComparePreview] Original text sample:', originalText.substring(0, 100)); // console.log('[ComparePreview] Modified text sample:', modifiedText.substring(0, 100)); } } return; // Stop polling } // If still null and haven't exceeded max retries, try again if (retryCount < maxRetries) { setTimeout(pollForDiffChanges, retryInterval); } else { console.error(`[ComparePreview] ❌ Failed to get diff results after ${maxRetries} attempts`); setDiffCount(0); } }; // Start polling after a short delay setTimeout(pollForDiffChanges, 100); }; // Add highlight flash animation when navigating to a difference const addFlashHighlight = (editor: editor.ICodeEditor, lineNumber: number) => { const decorations = editor.deltaDecorations([], [ { range: new (window as any).monaco.Range(lineNumber, 1, lineNumber, 1), options: { isWholeLine: true, className: 'diff-flash-highlight', } } ]); // Remove the highlight after animation completes setTimeout(() => { editor.deltaDecorations(decorations, []); }, 1000); }; // Go to next difference const goToNextDiff = () => { if (!diffEditorRef.current) return; const lineChanges = diffEditorRef.current.getLineChanges(); if (!lineChanges || lineChanges.length === 0) return; const nextIndex = (currentDiff + 1) % lineChanges.length; const nextChange = lineChanges[nextIndex]; const modifiedEditor = diffEditorRef.current.getModifiedEditor(); modifiedEditor.revealLineInCenter(nextChange.modifiedStartLineNumber); modifiedEditor.setPosition({ lineNumber: nextChange.modifiedStartLineNumber, column: 1 }); // Add flash highlight addFlashHighlight(modifiedEditor, nextChange.modifiedStartLineNumber); setCurrentDiff(nextIndex); }; // Go to previous difference const goToPreviousDiff = () => { if (!diffEditorRef.current) return; const lineChanges = diffEditorRef.current.getLineChanges(); if (!lineChanges || lineChanges.length === 0) return; const prevIndex = currentDiff === 0 ? lineChanges.length - 1 : currentDiff - 1; const prevChange = lineChanges[prevIndex]; const modifiedEditor = diffEditorRef.current.getModifiedEditor(); modifiedEditor.revealLineInCenter(prevChange.modifiedStartLineNumber); modifiedEditor.setPosition({ lineNumber: prevChange.modifiedStartLineNumber, column: 1 }); // Add flash highlight addFlashHighlight(modifiedEditor, prevChange.modifiedStartLineNumber); setCurrentDiff(prevIndex); }; // Load documents on mount useEffect(() => { // console.log('[ComparePreview] Doc1 path changed:', doc1Path); if (doc1Path) { loadDocumentAndExtractText(doc1Path, setDoc1Info, setIsLoadingDoc1, setOriginalText); } else { setOriginalText(''); } }, [doc1Path]); useEffect(() => { // console.log('[ComparePreview] Doc2 path changed:', doc2Path); if (doc2Path) { loadDocumentAndExtractText(doc2Path, setDoc2Info, setIsLoadingDoc2, setModifiedText); } else { setModifiedText(''); } }, [doc2Path]); // Recalculate differences when text changes useEffect(() => { // console.log('[ComparePreview] Text changed, recalculating differences...', { // originalTextLength: originalText.length, // modifiedTextLength: modifiedText.length, // hasEditor: !!diffEditorRef.current // }); const timer = setTimeout(() => { if (diffEditorRef.current) { const lineChanges = diffEditorRef.current.getLineChanges(); if (lineChanges) { setDiffCount(lineChanges.length); // console.log(`[ComparePreview] Recalculated differences: ${lineChanges.length} found`); // 如果文本都不为空但差异为0,打印警告 if (lineChanges.length === 0 && originalText.length > 0 && modifiedText.length > 0) { console.warn('[ComparePreview] Warning: Both texts loaded but 0 differences found!', { doc1PathsMatch: doc1Path === doc2Path, originalSample: originalText.substring(0, 50), modifiedSample: modifiedText.substring(0, 50) }); } } } }, 100); return () => clearTimeout(timer); }, [originalText, modifiedText, doc1Path, doc2Path]); return (
{/* Character-level diff highlighting styles */} {/* Toolbar and Info banner - combined in one row */}
{/* Left side: Toolbar controls */}
{/* Diff statistics */}
发现 {diffCount} 处差异 {diffCount > 0 && ` (当前: ${currentDiff + 1}/${diffCount})`}
{/* Navigation buttons */}
{/* Right side: Info banner */}
差异高亮说明: 左侧红色:原始版本 | 右侧绿色:比对版本 | 深色高亮:字符差异
{/* Diff Editor main area */}
{/* 只有当两个文本都加载完成后才渲染 Monaco Editor */} {originalText && modifiedText && !isLoadingDoc1 && !isLoadingDoc2 ? ( ) : (
{isLoadingDoc1 || isLoadingDoc2 ? ( 正在加载文档... ) : ( 等待文档加载... )}
)} {/* Loading overlay */} {(isLoadingDoc1 || isLoadingDoc2) && (
正在加载文档并提取文本...
{isLoadingDoc1 &&
📄 加载原始文档
} {isLoadingDoc2 &&
📄 加载对比文档
}
)}
{/* Spinner animation */}
); }