Files
leaudit-platform-frontend/app/components/reviews/previewComponents/ComparePreview.tsx
T
LiangShiyong 4fcc92a381 feat: 1. 接入CollaboraViewer选中的高亮效果,清除高亮功能,页面销毁自动清除高亮。
2. 合同模板对比接入monaco editor的效果。
3. 添加交叉评查的案卷类型的数据查询。

fix: 1. 修复文档列表的打开模态框蒙板层显示效果。
2025-11-30 19:33:05 +08:00

702 lines
23 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.
/**
* 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 } 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';
// 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 (
<div style={{
height: '100%',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '40px',
backgroundColor: '#f9f9f9'
}}>
<div style={{
maxWidth: '500px',
textAlign: 'center',
padding: '32px',
backgroundColor: '#fff',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
border: '1px solid #e0e0e0'
}}>
<div style={{
width: '64px',
height: '64px',
margin: '0 auto 24px',
backgroundColor: '#fff3cd',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '3px solid #ffc107'
}}>
<i className="ri-file-warning-line" style={{ fontSize: '32px', color: '#ff9800' }}></i>
</div>
<h3 style={{
fontSize: '20px',
fontWeight: '600',
color: '#333',
marginBottom: '12px'
}}>
</h3>
<p style={{
fontSize: '14px',
color: '#666',
lineHeight: '1.6',
marginBottom: '16px'
}}>
</p>
<div style={{
padding: '12px 16px',
backgroundColor: '#fff3cd',
border: '1px solid #ffc107',
borderRadius: '4px',
fontSize: '13px',
color: '#856404',
lineHeight: '1.5'
}}>
<i className="ri-error-warning-line" style={{ marginRight: '6px' }}></i>
<strong></strong>
</div>
</div>
</div>
);
}
const [originalText, setOriginalText] = useState<string>('');
const [modifiedText, setModifiedText] = useState<string>('');
const diffEditorRef = useRef<editor.IStandaloneDiffEditor | null>(null);
const [diffCount, setDiffCount] = useState<number>(0);
const [currentDiff, setCurrentDiff] = useState<number>(0);
const [doc1Info, setDoc1Info] = useState<DocumentInfo | null>(null);
const [doc2Info, setDoc2Info] = useState<DocumentInfo | null>(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<PdfInfo> => {
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<string> => {
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<string> => {
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 (
<div className="compare-preview-container" style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
{/* Character-level diff highlighting styles */}
<style>{`
.compare-preview-container {
height: 100%;
width: 100%;
}
.monaco-diff-editor .char-insert {
background-color: rgba(100, 150, 50, 0.6) !important;
}
.monaco-diff-editor .char-delete {
background-color: rgba(200, 50, 50, 0.5) !important;
}
.monaco-diff-editor .line-insert .char-insert {
background-color: rgba(100, 150, 50, 0.7) !important;
}
.monaco-diff-editor .line-delete .char-delete {
background-color: rgba(200, 50, 50, 0.6) !important;
}
/* Flash highlight animation for navigation */
.diff-flash-highlight {
background-color: rgba(255, 200, 0, 0.3) !important;
animation: flash-pulse 1s ease-in-out;
}
@keyframes flash-pulse {
0%, 100% { background-color: rgba(255, 200, 0, 0); }
50% { background-color: rgba(255, 200, 0, 0.5); }
}
/* Navigation button focus styles */
.nav-diff-button {
padding: 6px 12px;
background-color: #fff;
border: 1px solid #d0d0d0;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s ease;
}
.nav-diff-button:hover:not(:disabled) {
background-color: #f0f0f0;
border-color: #00684a;
}
.nav-diff-button:focus {
outline: none;
border-color: #00684a;
box-shadow: 0 0 0 2px rgba(0, 104, 74, 0.2);
}
.nav-diff-button:active:not(:disabled) {
background-color: #e0e0e0;
}
.nav-diff-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
`}</style>
{/* Toolbar and Info banner - combined in one row */}
<div style={{
padding: '12px 16px',
borderBottom: '1px solid #e0e0e0',
backgroundColor: '#f5f5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '16px'
}}>
{/* Left side: Toolbar controls */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexShrink: 0 }}>
{/* Diff statistics */}
<div style={{
padding: '6px 12px',
backgroundColor: '#fff',
border: '1px solid #d0d0d0',
borderRadius: '4px',
fontSize: '14px',
color: '#333'
}}>
<i className="ri-git-compare-line" style={{ marginRight: '6px', color: '#00684a' }}></i>
<strong style={{ color: '#00684a' }}>{diffCount}</strong>
{diffCount > 0 && ` (当前: ${currentDiff + 1}/${diffCount})`}
</div>
{/* Navigation buttons */}
<button
className="nav-diff-button"
onClick={goToPreviousDiff}
disabled={diffCount === 0}
>
<i className="ri-arrow-up-line"></i>
</button>
<button
className="nav-diff-button"
onClick={goToNextDiff}
disabled={diffCount === 0}
>
<i className="ri-arrow-down-line"></i>
</button>
</div>
{/* Right side: Info banner */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '6px 12px',
backgroundColor: '#e7f3ff',
border: '1px solid #b3d9ff',
borderRadius: '4px',
fontSize: '13px',
color: '#004085',
flexShrink: 1,
minWidth: 0
}}>
<i className="ri-information-line" style={{ fontSize: '16px', flexShrink: 0 }}></i>
<div style={{ lineHeight: '1.5', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
<strong></strong>
<span style={{ marginLeft: '8px' }}>
<span style={{ color: '#dc3545', fontWeight: 'bold' }}></span> |
<span style={{ color: '#28a745', fontWeight: 'bold', marginLeft: '8px' }}>绿</span> |
<span style={{ color: '#666', fontWeight: 'bold', marginLeft: '8px' }}></span>
</span>
</div>
</div>
</div>
{/* Diff Editor main area */}
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
{/* 只有当两个文本都加载完成后才渲染 Monaco Editor */}
{originalText && modifiedText && !isLoadingDoc1 && !isLoadingDoc2 ? (
<DiffEditor
key={`${doc1Path}-${doc2Path}-${originalText.length}-${modifiedText.length}`}
height="100%"
language="plaintext"
original={originalText}
modified={modifiedText}
onMount={handleEditorDidMount}
theme="vs"
options={{
readOnly: true,
renderSideBySide: true,
ignoreTrimWhitespace: false,
renderWhitespace: 'selection',
fontSize: 14,
lineNumbers: 'on',
minimap: {
enabled: true
},
scrollBeyondLastLine: false,
wordWrap: 'on',
automaticLayout: true,
renderIndicators: true,
diffWordWrap: 'on',
enableSplitViewResizing: true,
diffAlgorithm: 'advanced',
}}
/>
) : (
<div style={{
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#666',
fontSize: '14px'
}}>
{isLoadingDoc1 || isLoadingDoc2 ? (
<span>...</span>
) : (
<span>...</span>
)}
</div>
)}
{/* Loading overlay */}
{(isLoadingDoc1 || isLoadingDoc2) && (
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{ textAlign: 'center' }}>
<div style={{
width: '48px',
height: '48px',
border: '4px solid #f3f3f3',
borderTop: '4px solid #00684a',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
margin: '0 auto 16px'
}}></div>
<div style={{ fontSize: '16px', color: '#333' }}>
...
</div>
{isLoadingDoc1 && <div style={{ fontSize: '14px', color: '#666', marginTop: '8px' }}>📄 </div>}
{isLoadingDoc2 && <div style={{ fontSize: '14px', color: '#666', marginTop: '8px' }}>📄 </div>}
</div>
</div>
)}
</div>
{/* Spinner animation */}
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</div>
);
}