/** * 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 [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 result = await mammoth.extractRawText({ arrayBuffer }); return result.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 const buildFileUrl = (filePath: string): string => { // 如果路径以 public/ 开头或者以已知的 public 子目录开头(如 testWork/) // 则直接使用静态资源路径 if (filePath.startsWith('public/')) { // 去掉 public/ 前缀,直接访问静态资源 return '/' + filePath.substring(7); } else if (filePath.startsWith('testWork/') || filePath.startsWith('testPDF/')) { // testWork 和 testPDF 目录在 public 下,直接作为静态资源访问 return '/' + filePath; } else { // 其他路径通过 api/pdf-proxy 代理访问(从 MinIO 获取) return `/api/pdf-proxy?path=${encodeURIComponent(filePath)}`; } }; // 从URL参数加载文档(支持 PDF 和 Word) const loadDocumentsFromUrl = () => { if (typeof window === 'undefined') return; const searchParams = new URLSearchParams(window.location.search); const doc1Path = searchParams.get('doc1') || searchParams.get('pdf1'); // 兼容旧参数名 const doc2Path = searchParams.get('doc2') || searchParams.get('pdf2'); // 兼容旧参数名 if (doc1Path || doc2Path) { setUseExample(false); if (doc1Path) { const fullUrl = buildFileUrl(doc1Path); setDoc1Url(fullUrl); loadDocumentAndExtractText(fullUrl, doc1Path, setDoc1Info, setIsLoadingDoc1, setOriginalText); } if (doc2Path) { const fullUrl = buildFileUrl(doc2Path); setDoc2Url(fullUrl); loadDocumentAndExtractText(fullUrl, doc2Path, setDoc2Info, setIsLoadingDoc2, setModifiedText); } } }; // 组件挂载时读取URL参数 useEffect(() => { loadDocumentsFromUrl(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (
{/* 页面头部 */}

Monaco Editor - 合同差异对比演示

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

{/* 工具栏 */}
{/* 差异统计 */}
发现 {diffCount} 处差异 {diffCount > 0 && ` (当前: ${currentDiff + 1}/${diffCount})`}
{/* 导航按钮 */}
{/* 重置按钮 */} {/* 未来扩展:上传按钮 */}
{/* 文档加载信息 */} {!useExample && (doc1Info || doc2Info || isLoadingDoc1 || isLoadingDoc2) && (
文档信息:
{/* 文档 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
PDF示例: /monaco-demo?doc1=documents/contract_v1.pdf&doc2=documents/contract_v2.pdf
Word示例: /monaco-demo?doc1=testWork/(最终版)智慧法务平台建设采购项目合同(1).docx&doc2=testWork/(最终版)智慧法务平台建设采购项目合同(2).docx
)}
{/* Diff Editor 主体 */}
{/* 文档加载中的遮罩层 */} {(isLoadingDoc1 || isLoadingDoc2) && (
正在加载文档并提取文本...
{isLoadingDoc1 &&
📄 加载文档1
} {isLoadingDoc2 &&
📄 加载文档2
}
)}
{/* 添加旋转动画 */} {/* 页面底部信息 */}
基于 Monaco Editor (VS Code 核心编辑器) 提示:可使用鼠标滚轮缩放,Ctrl+F 搜索
); }