Files
leaudit-platform-frontend/app/routes/monaco-demo.tsx
T

656 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<editor.IStandaloneDiffEditor | null>(null);
const [diffCount, setDiffCount] = useState<number>(0);
const [currentDiff, setCurrentDiff] = useState<number>(0);
// PDF相关状态
const [pdf1Url, setPdf1Url] = useState<string>('');
const [pdf2Url, setPdf2Url] = useState<string>('');
const [pdf1Info, setPdf1Info] = useState<PdfInfo | null>(null);
const [pdf2Info, setPdf2Info] = useState<PdfInfo | null>(null);
const [isLoadingPdf1, setIsLoadingPdf1] = useState(false);
const [isLoadingPdf2, setIsLoadingPdf2] = useState(false);
const [useExample, setUseExample] = useState(true);
// PDF类型检测函数
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); // 检查前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<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========== 第 ${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 (
<div className="monaco-demo-page" style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* 页面头部 */}
<div style={{
padding: '16px 24px',
borderBottom: '1px solid #e0e0e0',
backgroundColor: '#fff',
boxShadow: '0 2px 4px rgba(0,0,0,0.05)'
}}>
<h1 style={{ margin: 0, fontSize: '24px', fontWeight: 600, color: '#333' }}>
<i className="ri-file-text-line" style={{ marginRight: '8px' }}></i>
Monaco Editor -
</h1>
<p style={{ margin: '8px 0 0 0', color: '#666', fontSize: '14px' }}>
使 Monaco Diff Editor
</p>
</div>
{/* 工具栏 */}
<div style={{
padding: '12px 24px',
borderBottom: '1px solid #e0e0e0',
backgroundColor: '#f5f5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{/* 差异统计 */}
<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>
{/* 导航按钮 */}
<button
onClick={goToPreviousDiff}
disabled={diffCount === 0}
style={{
padding: '6px 12px',
backgroundColor: '#fff',
border: '1px solid #d0d0d0',
borderRadius: '4px',
cursor: diffCount === 0 ? 'not-allowed' : 'pointer',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '6px',
opacity: diffCount === 0 ? 0.5 : 1
}}
>
<i className="ri-arrow-up-line"></i>
</button>
<button
onClick={goToNextDiff}
disabled={diffCount === 0}
style={{
padding: '6px 12px',
backgroundColor: '#fff',
border: '1px solid #d0d0d0',
borderRadius: '4px',
cursor: diffCount === 0 ? 'not-allowed' : 'pointer',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '6px',
opacity: diffCount === 0 ? 0.5 : 1
}}
>
<i className="ri-arrow-down-line"></i>
</button>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
{/* 重置按钮 */}
<button
onClick={resetToExample}
style={{
padding: '6px 12px',
backgroundColor: '#fff',
border: '1px solid #d0d0d0',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}
>
<i className="ri-refresh-line"></i>
</button>
{/* 未来扩展:上传按钮 */}
<button
disabled
style={{
padding: '6px 12px',
backgroundColor: '#e0e0e0',
border: '1px solid #d0d0d0',
borderRadius: '4px',
cursor: 'not-allowed',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '6px',
opacity: 0.5
}}
>
<i className="ri-upload-2-line"></i>
</button>
</div>
</div>
{/* PDF加载信息 */}
{!useExample && (pdf1Info || pdf2Info || isLoadingPdf1 || isLoadingPdf2) && (
<div style={{
padding: '12px 24px',
backgroundColor: '#fff3cd',
borderBottom: '1px solid #ffc107',
fontSize: '14px',
color: '#856404'
}}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '24px' }}>
<i className="ri-file-pdf-line" style={{ fontSize: '18px', marginTop: '2px' }}></i>
<div style={{ flex: 1 }}>
<strong>PDF文档信息</strong>
<div style={{ display: 'flex', gap: '24px', marginTop: '8px' }}>
{/* PDF 1 信息 */}
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>📄 1/</div>
{isLoadingPdf1 ? (
<div style={{ color: '#666' }}> ...</div>
) : pdf1Info ? (
<div>
<div>: <span style={{
color: pdf1Info.type === 'text' ? '#28a745' : pdf1Info.type === 'scanned' ? '#ffc107' : '#dc3545',
fontWeight: 'bold'
}}>
{pdf1Info.type === 'text' ? '✅ 文本PDF' : pdf1Info.type === 'scanned' ? '⚠️ 扫描PDF' : '❌ 未知类型'}
</span></div>
<div>: {pdf1Info.numPages} </div>
<div>: {pdf1Info.textLength} </div>
<div>: {(pdf1Info.confidence * 100).toFixed(0)}%</div>
</div>
) : (
<div style={{ color: '#999' }}></div>
)}
</div>
{/* PDF 2 信息 */}
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>📄 2/</div>
{isLoadingPdf2 ? (
<div style={{ color: '#666' }}> ...</div>
) : pdf2Info ? (
<div>
<div>: <span style={{
color: pdf2Info.type === 'text' ? '#28a745' : pdf2Info.type === 'scanned' ? '#ffc107' : '#dc3545',
fontWeight: 'bold'
}}>
{pdf2Info.type === 'text' ? '✅ 文本PDF' : pdf2Info.type === 'scanned' ? '⚠️ 扫描PDF' : '❌ 未知类型'}
</span></div>
<div>: {pdf2Info.numPages} </div>
<div>: {pdf2Info.textLength} </div>
<div>: {(pdf2Info.confidence * 100).toFixed(0)}%</div>
</div>
) : (
<div style={{ color: '#999' }}></div>
)}
</div>
</div>
</div>
</div>
</div>
)}
{/* 说明信息 */}
<div style={{
padding: '12px 24px',
backgroundColor: '#e7f3ff',
borderBottom: '1px solid #b3d9ff',
fontSize: '14px',
color: '#004085'
}}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
<i className="ri-information-line" style={{ fontSize: '18px', marginTop: '2px' }}></i>
<div style={{ flex: 1 }}>
<strong></strong>
<ul style={{ margin: '4px 0 0 0', paddingLeft: '20px' }}>
<li><span style={{ color: '#28a745', fontWeight: 'bold' }}>绿</span></li>
<li><span style={{ color: '#dc3545', fontWeight: 'bold' }}></span></li>
<li><span style={{ color: '#ffc107', fontWeight: 'bold' }}></span></li>
</ul>
{useExample && (
<div style={{ marginTop: '12px', paddingTop: '12px', borderTop: '1px solid #b3d9ff' }}>
<strong>💡 使</strong>
<div style={{ marginTop: '4px' }}>
URL参数加载PDF文档进行对比
<code style={{
display: 'block',
marginTop: '4px',
padding: '8px',
backgroundColor: 'rgba(0,0,0,0.05)',
borderRadius: '4px',
fontSize: '12px',
wordBreak: 'break-all'
}}>
/monaco-demo?pdf1=1&pdf2=2
</code>
<div style={{ marginTop: '4px', fontSize: '12px' }}>
: <code>/monaco-demo?pdf1=documents/contract_v1.pdf&pdf2=documents/contract_v2.pdf</code>
</div>
</div>
</div>
)}
</div>
</div>
</div>
{/* Diff Editor 主体 */}
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
<DiffEditor
height="100%"
language="plaintext"
original={originalText}
modified={modifiedText}
onMount={handleEditorDidMount}
theme="vs"
options={{
// 编辑器选项
readOnly: true, // 只读模式
renderSideBySide: true, // 并排显示(false为内联模式)
ignoreTrimWhitespace: false, // 不忽略首尾空格差异
renderWhitespace: 'selection', // 显示选中区域的空格
fontSize: 14, // 字体大小
lineNumbers: 'on', // 显示行号
minimap: {
enabled: true // 显示缩略图
},
scrollBeyondLastLine: false, // 禁止滚动超过最后一行
wordWrap: 'on', // 自动换行
automaticLayout: true, // 自动调整布局
// Diff 特定选项
renderIndicators: true, // 显示差异指示器
diffWordWrap: 'on', // Diff 模式下自动换行
enableSplitViewResizing: true, // 允许调整分屏比例
diffAlgorithm: 'advanced', // 使用高级差异算法
}}
/>
{/* PDF加载中的遮罩层 */}
{(isLoadingPdf1 || isLoadingPdf2) && (
<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' }}>
PDF文档并提取文本...
</div>
{isLoadingPdf1 && <div style={{ fontSize: '14px', color: '#666', marginTop: '8px' }}>📄 1</div>}
{isLoadingPdf2 && <div style={{ fontSize: '14px', color: '#666', marginTop: '8px' }}>📄 2</div>}
</div>
</div>
)}
</div>
{/* 添加旋转动画 */}
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
{/* 页面底部信息 */}
<div style={{
padding: '8px 24px',
borderTop: '1px solid #e0e0e0',
backgroundColor: '#f9f9f9',
fontSize: '12px',
color: '#666',
display: 'flex',
justifyContent: 'space-between'
}}>
<span>
<i className="ri-code-line"></i> Monaco Editor (VS Code )
</span>
<span>
使Ctrl+F
</span>
</div>
</div>
);
}