/** * Monaco Editor 差异对比演示页面 * * 功能: * - 展示两份合同文本的差异对比 * - 支持逐行高亮显示差异 * - 提供差异导航功能 * - 后续可扩展文件上传功能 */ import { type MetaFunction } from "@remix-run/node"; import { 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'; // 设置 PDF.js worker(与 pdf-demo.tsx 相同) pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.js'; export const meta: MetaFunction = () => { return [ { title: "Monaco Diff Editor 演示 - 合同对比" }, { name: "description", content: "使用 Monaco Editor 进行合同文本差异对比" } ]; }; // 文档类型枚举 type DocumentType = 'pdf' | 'docx' | 'unknown'; // PDF 类型枚举 type PdfType = 'text' | 'scanned' | 'unknown'; // PDF 信息接口(内部使用) interface PdfInfo { type: PdfType; numPages: number; textLength: number; confidence: number; } // 文档信息接口 interface DocumentInfo { fileType: DocumentType; pdfType?: PdfType; // 只有 PDF 才有 numPages?: number; // PDF 页数 textLength: number; confidence: number; // 文本提取置信度 (0-1) } // 示例合同文本 A(原始版本) const CONTRACT_A = `中国烟草合同(原始版本) 第一条 合同双方 甲方:中国烟草总公司广东省公司 乙方:XX供应商有限公司 第二条 合同标的 甲方向乙方采购烟草包装材料,具体型号为: 1. 硬盒包装纸 10000箱 2. 烟用滤棒 5000箱 总金额:人民币壹佰万元整(¥1,000,000.00) 第三条 交付时间 乙方应在签订合同后30个工作日内完成全部交付。 第四条 质量标准 产品应符合国家烟草行业标准 YC/T 207-2014。 第五条 付款方式 甲方在收到货物并验收合格后,于15个工作日内支付全部款项。 第六条 违约责任 1. 乙方延期交付,每延迟一天支付合同总额0.5%的违约金。 2. 产品质量不合格,乙方应无偿更换并承担相应损失。 第七条 争议解决 本合同履行过程中发生的争议,由双方协商解决;协商不成的,提交广州仲裁委员会仲裁。 第八条 其他约定 本合同一式两份,甲乙双方各执一份,具有同等法律效力。 签订日期:2024年1月15日 `; // 示例合同文本 B(修改版本) const CONTRACT_B = `中国烟草合同(修订版本) 第一条 合同双方 甲方:中国烟草总公司广东省公司 乙方:XX供应商有限公司 第二条 合同标的 甲方向乙方采购烟草包装材料,具体型号为: 1. 硬盒包装纸 15000箱(数量增加) 2. 烟用滤棒 5000箱 3. 商标纸 3000箱(新增项目) 总金额:人民币壹佰伍拾万元整(¥1,500,000.00) 第三条 交付时间 乙方应在签订合同后45个工作日内完成全部交付。 如遇不可抗力,交付时间可顺延,但乙方应及时通知甲方。 第四条 质量标准 产品应符合国家烟草行业标准 YC/T 207-2014 及甲方企业标准。 第五条 付款方式 1. 签订合同后,甲方支付合同总额30%作为预付款; 2. 收到货物并验收合格后,于15个工作日内支付剩余70%款项。 第六条 违约责任 1. 乙方延期交付,每延迟一天支付合同总额1%的违约金(违约金比例提高)。 2. 产品质量不合格,乙方应无偿更换并承担相应损失。 3. 甲方延期付款,每延迟一天支付未付款项0.05%的违约金。 第七条 保密条款(新增) 双方应对合同内容及执行过程中获悉的商业秘密承担保密义务,保密期限为合同终止后2年。 第八条 争议解决 本合同履行过程中发生的争议,由双方协商解决;协商不成的,提交广州仲裁委员会仲裁。 第九条 其他约定 本合同一式两份,甲乙双方各执一份,具有同等法律效力。 签订日期:2024年3月20日 `; export default function MonacoDemoPage() { const [originalText, setOriginalText] = useState(CONTRACT_A); const [modifiedText, setModifiedText] = useState(CONTRACT_B); const diffEditorRef = useRef(null); const [diffCount, setDiffCount] = useState(0); const [currentDiff, setCurrentDiff] = useState(0); // 文档相关状态 // 默认使用的测试文档路径(相对路径) // 注意:文件名中使用的是中文全角括号()而不是英文半角括号() const DEFAULT_DOC1_URL = '/testWork/(最终版)智慧法务平台建设采购项目合同(1).docx'; const DEFAULT_DOC2_URL = '/testWork/(最终版)智慧法务平台建设采购项目合同(2).docx'; const [doc1Url, setDoc1Url] = useState(''); const [doc2Url, setDoc2Url] = useState(''); const [doc1Info, setDoc1Info] = useState(null); const [doc2Info, setDoc2Info] = useState(null); const [isLoadingDoc1, setIsLoadingDoc1] = useState(false); const [isLoadingDoc2, setIsLoadingDoc2] = useState(false); const [useExample, setUseExample] = useState(true); // 检测文件类型(根据文件路径) 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类型检测函数 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); // 检查前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; // 计算置信度(0-1) const confidence = Math.min(avgTextPerPage / 500, 1); // 判断PDF类型 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文本提取函数 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========== 第 ${i} 页 ==========\n${pageText}\n`; } return fullText; }; // Word文档文本提取函数 const extractTextFromWord = async (docUrl: string): Promise => { // 通过 fetch 获取文件 const response = await fetch(docUrl); if (!response.ok) { throw new Error(`无法加载文档: ${response.statusText}`); } // 获取 ArrayBuffer const arrayBuffer = await response.arrayBuffer(); // 使用 mammoth 提取纯文本 const textResult = await mammoth.extractRawText({ arrayBuffer }); return textResult.value; }; // 加载文档并提取文本(支持 PDF 和 Word) const loadDocumentAndExtractText = async ( docUrl: string, filePath: string, setDocInfo: (info: DocumentInfo | null) => void, setLoading: (loading: boolean) => void, setTextContent: (text: string) => void ) => { try { setLoading(true); // 1. 检测文件类型 const fileType = detectFileType(filePath); if (fileType === 'pdf') { // 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); 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') { // Word 处理 const text = await extractTextFromWord(docUrl); const docInfo: DocumentInfo = { fileType: 'docx', textLength: text.length, confidence: 1.0 // Word 文档文本提取置信度为 100% }; setDocInfo(docInfo); setTextContent(text); toastService.success(`Word文档加载成功!提取了 ${text.length} 个字符`); } else { toastService.error('不支持的文件类型'); setTextContent(''); } } catch (error) { console.error('文档加载失败:', error); toastService.error(`文档加载失败: ${error instanceof Error ? error.message : '未知错误'}`); setDocInfo(null); setTextContent(''); } finally { setLoading(false); } }; // Monaco Editor 挂载后的回调 const handleEditorDidMount = (editor: editor.IStandaloneDiffEditor) => { diffEditorRef.current = editor; // 获取差异数量 const lineChanges = editor.getLineChanges(); if (lineChanges) { setDiffCount(lineChanges.length); console.log(`发现 ${lineChanges.length} 处差异:`, lineChanges); } }; // 跳转到下一个差异 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 }); setCurrentDiff(nextIndex); }; // 跳转到上一个差异 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 }); setCurrentDiff(prevIndex); }; // 重置为示例文本 const resetToExample = () => { setOriginalText(CONTRACT_A); setModifiedText(CONTRACT_B); setCurrentDiff(0); setUseExample(true); setDoc1Info(null); setDoc2Info(null); // 重新计算差异数量 setTimeout(() => { if (diffEditorRef.current) { const lineChanges = diffEditorRef.current.getLineChanges(); if (lineChanges) { setDiffCount(lineChanges.length); } } }, 100); }; // 从URL参数加载文档(支持 PDF 和 Word) const loadDocumentsFromUrl = () => { if (typeof window === 'undefined') return; const searchParams = new URLSearchParams(window.location.search); const doc1Param = searchParams.get('doc1') || searchParams.get('pdf1'); // 兼容旧参数名 const doc2Param = searchParams.get('doc2') || searchParams.get('pdf2'); // 兼容旧参数名 // 只有在传参时才加载文档,否则使用默认的 mock 数据(示例合同) if (doc1Param || doc2Param) { setUseExample(false); // 文档1 if (doc1Param) { const doc1Url = doc1Param.startsWith('/') ? doc1Param : '/' + doc1Param; // 相对路径,确保以 / 开头 setDoc1Url(doc1Url); loadDocumentAndExtractText(doc1Url, doc1Param, setDoc1Info, setIsLoadingDoc1, setOriginalText); } // 文档2 if (doc2Param) { const doc2Url = doc2Param.startsWith('/') ? doc2Param : '/' + doc2Param; // 相对路径,确保以 / 开头 setDoc2Url(doc2Url); loadDocumentAndExtractText(doc2Url, doc2Param, setDoc2Info, setIsLoadingDoc2, setModifiedText); } } // 如果没有传参,则保持 useExample=true,显示默认的 CONTRACT_A 和 CONTRACT_B }; // 组件挂载时读取URL参数 useEffect(() => { loadDocumentsFromUrl(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // 监听文本变化,重新计算差异数量 useEffect(() => { // 延迟一点确保编辑器已经渲染完成 const timer = setTimeout(() => { if (diffEditorRef.current) { const lineChanges = diffEditorRef.current.getLineChanges(); if (lineChanges) { setDiffCount(lineChanges.length); console.log(`✅ 重新计算差异: 发现 ${lineChanges.length} 处差异`); } } }, 100); return () => clearTimeout(timer); }, [originalText, modifiedText]); // 当文本内容变化时重新计算 return (
{/* 字符级差异高亮样式 - 加深高亮颜色 */} {/* 页面头部 */}

Monaco Editor - 合同差异对比演示

使用 Monaco Diff Editor 逐行对比两份合同文本的差异

{/* 工具栏 */}
{/* 差异统计 */}
发现 {diffCount} 处差异 {diffCount > 0 && ` (当前: ${currentDiff + 1}/${diffCount})`}
{/* 导航按钮 */}
{/* 重置按钮 */}
{/* 文档加载信息 */} {!useExample && (doc1Info || doc2Info || isLoadingDoc1 || isLoadingDoc2) && 2==1 && (
文档信息:
{/* 文档 1 信息 */}
📄 文档1(左侧/原始)
{isLoadingDoc1 ? (
⏳ 加载中...
) : doc1Info ? (
类型: {doc1Info.fileType === 'pdf' ? '📕 PDF文档' : doc1Info.fileType === 'docx' ? '📘 Word文档' : '❌ 未知类型'}
{doc1Info.fileType === 'pdf' && doc1Info.numPages && (
页数: {doc1Info.numPages} 页
)} {doc1Info.fileType === 'pdf' && doc1Info.pdfType && (
PDF类型: {doc1Info.pdfType === 'text' ? '✅ 文本' : doc1Info.pdfType === 'scanned' ? '⚠️ 扫描' : '❌ 未知'}
)}
字符数: {doc1Info.textLength} 个
置信度: {(doc1Info.confidence * 100).toFixed(0)}%
) : (
未加载
)}
{/* 文档 2 信息 */}
📄 文档2(右侧/修改)
{isLoadingDoc2 ? (
⏳ 加载中...
) : doc2Info ? (
类型: {doc2Info.fileType === 'pdf' ? '📕 PDF文档' : doc2Info.fileType === 'docx' ? '📘 Word文档' : '❌ 未知类型'}
{doc2Info.fileType === 'pdf' && doc2Info.numPages && (
页数: {doc2Info.numPages} 页
)} {doc2Info.fileType === 'pdf' && doc2Info.pdfType && (
PDF类型: {doc2Info.pdfType === 'text' ? '✅ 文本' : doc2Info.pdfType === 'scanned' ? '⚠️ 扫描' : '❌ 未知'}
)}
字符数: {doc2Info.textLength} 个
置信度: {(doc2Info.confidence * 100).toFixed(0)}%
) : (
未加载
)}
)} {/* 说明信息 */}
差异高亮说明:
  • 左侧红色背景:原始版本的内容(在新版本中被删除或修改前的内容)
  • 右侧绿色背景:修改版本的内容(新增或修改后的内容)
  • 深色高亮:行内具体修改的字符差异
{useExample && (
💡 使用提示:
您可以通过URL参数加载文档进行对比(支持 PDF 和 Word,使用相对路径): /monaco-demo?doc1=相对路径1&doc2=相对路径2
Word示例: /monaco-demo?doc1=testWork/(最终版)智慧法务平台建设采购项目合同(1).docx&doc2=testWork/(最终版)智慧法务平台建设采购项目合同(2).docx
PDF示例: /monaco-demo?doc1=testPDF/sample1.pdf&doc2=testPDF/sample2.pdf
)}
{/* Diff Editor 主体 */}
{/* 文档加载中的遮罩层 */} {(isLoadingDoc1 || isLoadingDoc2) && (
正在加载文档并提取文本...
{isLoadingDoc1 &&
📄 加载文档1
} {isLoadingDoc2 &&
📄 加载文档2
}
)}
{/* 添加旋转动画 */} {/* 页面底部信息 */}
基于 Monaco Editor (VS Code 核心编辑器) 提示:可使用鼠标滚轮缩放,Ctrl+F 搜索
); }