/** * 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 { 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 进行合同文本差异对比" } ]; }; // PDF 类型枚举 type PdfType = 'text' | 'scanned' | 'unknown'; // PDF 信息接口 interface PdfInfo { type: PdfType; numPages: number; 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); // PDF相关状态 const [pdf1Url, setPdf1Url] = useState(''); const [pdf2Url, setPdf2Url] = useState(''); const [pdf1Info, setPdf1Info] = useState(null); const [pdf2Info, setPdf2Info] = useState(null); const [isLoadingPdf1, setIsLoadingPdf1] = useState(false); const [isLoadingPdf2, setIsLoadingPdf2] = useState(false); const [useExample, setUseExample] = useState(true); // 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; }; // 加载PDF并提取文本 const loadPdfAndExtractText = async (pdfUrl: string, setPdfInfo: (info: PdfInfo | null) => void, setLoading: (loading: boolean) => void, setTextContent: (text: string) => void) => { try { setLoading(true); // 1. 检测PDF类型 const pdfInfo = await detectPdfType(pdfUrl); setPdfInfo(pdfInfo); // 2. 提取文本 if (pdfInfo.type === 'text') { const text = await extractTextFromPdf(pdfUrl); setTextContent(text); toastService.success(`PDF加载成功!共 ${pdfInfo.numPages} 页,提取了 ${pdfInfo.textLength} 个字符`); } else if (pdfInfo.type === 'scanned') { toastService.warning('检测到扫描版PDF,文本提取质量可能较低'); const text = await extractTextFromPdf(pdfUrl); setTextContent(text); } else { toastService.error('无法识别PDF类型,可能是图片PDF'); setTextContent(''); } } catch (error) { console.error('PDF加载失败:', error); toastService.error('PDF加载失败,请检查文件路径'); setPdfInfo(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); setPdf1Info(null); setPdf2Info(null); // 重新计算差异数量 setTimeout(() => { if (diffEditorRef.current) { const lineChanges = diffEditorRef.current.getLineChanges(); if (lineChanges) { setDiffCount(lineChanges.length); } } }, 100); }; // 从URL参数加载PDF const loadPdfsFromUrl = () => { if (typeof window === 'undefined') return; const searchParams = new URLSearchParams(window.location.search); const pdf1Path = searchParams.get('pdf1'); const pdf2Path = searchParams.get('pdf2'); if (pdf1Path || pdf2Path) { setUseExample(false); if (pdf1Path) { const fullUrl = `/api/pdf-proxy?path=${encodeURIComponent(pdf1Path)}`; setPdf1Url(fullUrl); loadPdfAndExtractText(fullUrl, setPdf1Info, setIsLoadingPdf1, setOriginalText); } if (pdf2Path) { const fullUrl = `/api/pdf-proxy?path=${encodeURIComponent(pdf2Path)}`; setPdf2Url(fullUrl); loadPdfAndExtractText(fullUrl, setPdf2Info, setIsLoadingPdf2, setModifiedText); } } }; // 组件挂载时读取URL参数 useEffect(() => { loadPdfsFromUrl(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (
{/* 页面头部 */}

Monaco Editor - 合同差异对比演示

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

{/* 工具栏 */}
{/* 差异统计 */}
发现 {diffCount} 处差异 {diffCount > 0 && ` (当前: ${currentDiff + 1}/${diffCount})`}
{/* 导航按钮 */}
{/* 重置按钮 */} {/* 未来扩展:上传按钮 */}
{/* PDF加载信息 */} {!useExample && (pdf1Info || pdf2Info || isLoadingPdf1 || isLoadingPdf2) && (
PDF文档信息:
{/* PDF 1 信息 */}
📄 文档1(左侧/原始)
{isLoadingPdf1 ? (
⏳ 加载中...
) : pdf1Info ? (
类型: {pdf1Info.type === 'text' ? '✅ 文本PDF' : pdf1Info.type === 'scanned' ? '⚠️ 扫描PDF' : '❌ 未知类型'}
页数: {pdf1Info.numPages} 页
字符数: {pdf1Info.textLength} 个
置信度: {(pdf1Info.confidence * 100).toFixed(0)}%
) : (
未加载
)}
{/* PDF 2 信息 */}
📄 文档2(右侧/修改)
{isLoadingPdf2 ? (
⏳ 加载中...
) : pdf2Info ? (
类型: {pdf2Info.type === 'text' ? '✅ 文本PDF' : pdf2Info.type === 'scanned' ? '⚠️ 扫描PDF' : '❌ 未知类型'}
页数: {pdf2Info.numPages} 页
字符数: {pdf2Info.textLength} 个
置信度: {(pdf2Info.confidence * 100).toFixed(0)}%
) : (
未加载
)}
)} {/* 说明信息 */}
差异高亮说明:
  • 绿色:新增的内容
  • 红色:删除的内容
  • 黄色背景:修改的行内差异
{useExample && (
💡 使用提示:
您可以通过URL参数加载PDF文档进行对比: /monaco-demo?pdf1=路径1&pdf2=路径2
示例: /monaco-demo?pdf1=documents/contract_v1.pdf&pdf2=documents/contract_v2.pdf
)}
{/* Diff Editor 主体 */}
{/* PDF加载中的遮罩层 */} {(isLoadingPdf1 || isLoadingPdf2) && (
正在加载PDF文档并提取文本...
{isLoadingPdf1 &&
📄 加载文档1
} {isLoadingPdf2 &&
📄 加载文档2
}
)}
{/* 添加旋转动画 */} {/* 页面底部信息 */}
基于 Monaco Editor (VS Code 核心编辑器) 提示:可使用鼠标滚轮缩放,Ctrl+F 搜索
); }