feat: 1. 接入CollaboraViewer选中的高亮效果,清除高亮功能,页面销毁自动清除高亮。

2. 合同模板对比接入monaco editor的效果。
3. 添加交叉评查的案卷类型的数据查询。

fix: 1. 修复文档列表的打开模态框蒙板层显示效果。
This commit is contained in:
2025-11-30 19:33:05 +08:00
parent fb67f138dc
commit 4fcc92a381
14 changed files with 1263 additions and 286 deletions
@@ -0,0 +1,701 @@
/**
* 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>
);
}