33f10896a0
2. 接入合同起草功能。
729 lines
23 KiB
TypeScript
729 lines
23 KiB
TypeScript
/**
|
||
* 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 (
|
||
<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>
|
||
);
|
||
}
|