feat: 1. 实现一键替换。

2. 优化追加附件和模板上传的样式。
This commit is contained in:
2025-12-03 12:07:56 +08:00
parent 2897423404
commit d88cfc818b
13 changed files with 627 additions and 141 deletions
+21
View File
@@ -337,6 +337,11 @@ export default function CrossCheckingResult() {
const [targetPage, setTargetPage] = useState<number | undefined>(undefined);
const [charPositions, setCharPositions] = useState<CharPosition[] | undefined>(undefined);
const [localScoringProposals, setLocalScoringProposals] = useState<ScoringProposal[]>(scoring_proposals || []); // 本地状态管理scoringProposals
const [aiSuggestionReplace, setAiSuggestionReplace] = useState<{
searchText: string;
replaceText: string;
pageNumber: number;
} | undefined>(undefined);
// 使用ref来跟踪loading状态,避免不必要的重新渲染
const isProcessingRef = useRef(false);
@@ -432,6 +437,19 @@ export default function CrossCheckingResult() {
setCharPositions(charPositions);
}
}, [activeReviewPointResultId]);
const handleAiSuggestionReplace = useCallback((searchText: string, replaceText: string, pageNumber: number) => {
console.log('[CrossCheckingResult] AI建议替换:', { searchText, replaceText, pageNumber });
setAiSuggestionReplace({
searchText,
replaceText,
pageNumber
});
// 重置状态,避免重复触发
setTimeout(() => {
setAiSuggestionReplace(undefined);
}, 1000);
}, []);
// 处理评审点状态变更
@@ -760,6 +778,7 @@ export default function CrossCheckingResult() {
activeReviewPointResultId={activeReviewPointResultId}
targetPage={targetPage}
charPositions={charPositions}
aiSuggestionReplace={aiSuggestionReplace}
/>
</div>
@@ -775,6 +794,8 @@ export default function CrossCheckingResult() {
jwtToken={jwtToken}
userInfo={userInfo}
onOpinionSubmitted={handleOpinionSubmitted}
fileFormat={reviewData.fileInfo.fileFormat}
onAiSuggestionReplace={handleAiSuggestionReplace}
/>
</div>
</div>
+34 -15
View File
@@ -791,21 +791,34 @@ export default function DocumentsIndex() {
const handleAttachmentFilesSelected = (files: FileList) => {
try {
console.log('【附件追加】开始处理附件文件选择, 文件数量:', files.length);
if (files.length > 0) {
// 检查主文件类型
const selectedDocument = documents.find(doc => doc.id === selectedDocumentId);
const isMainFileDocx = selectedDocument?.path.toLowerCase().endsWith('.docx');
// 验证文件类型,支持PDF、Word、ZIP、RAR
const validFiles: File[] = [];
let hasInvalidFiles = false;
let hasPdfForDocx = false;
Array.from(files).forEach(file => {
const fileName = file.name.toLowerCase();
const isValidType =
file.type === 'application/pdf' || fileName.endsWith('.pdf') ||
file.type === 'application/msword' || fileName.endsWith('.doc') ||
const isPdf = file.type === 'application/pdf' || fileName.endsWith('.pdf');
const isValidType =
isPdf ||
// file.type === 'application/msword' || fileName.endsWith('.doc') ||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || fileName.endsWith('.docx') ||
file.type === 'application/zip' || fileName.endsWith('.zip') ||
file.type === 'application/x-rar-compressed' || fileName.endsWith('.rar');
// 如果主文件是docx,不允许上传pdf附件
if (isMainFileDocx && isPdf) {
hasPdfForDocx = true;
console.error(`【附件追加】主文件为DOCX格式时不允许上传PDF附件: ${file.name}`);
return;
}
if (isValidType) {
validFiles.push(file);
} else {
@@ -813,15 +826,21 @@ export default function DocumentsIndex() {
console.error(`【附件追加】无效的文件类型: ${file.name}, 类型: ${file.type}`);
}
});
if (hasInvalidFiles) {
if (hasPdfForDocx) {
messageService.error('主文件为DOCX格式时,附件不可以是PDF格式', {
title: '文件类型限制',
confirmText: '确定',
cancelText: '',
});
} else if (hasInvalidFiles) {
messageService.error('只支持PDF、Word、ZIP、RAR格式的文件', {
title: '文件类型错误',
confirmText: '确定',
cancelText: '',
});
}
if (validFiles.length > 0) {
setAttachmentFiles(validFiles);
console.log('【附件追加】有效文件数量:', validFiles.length);
@@ -887,14 +906,14 @@ export default function DocumentsIndex() {
const fileName = file.name.toLowerCase();
const isValidType =
file.type === 'application/pdf' || fileName.endsWith('.pdf') ||
file.type === 'application/msword' || fileName.endsWith('.doc') ||
// file.type === 'application/msword' || fileName.endsWith('.doc') ||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || fileName.endsWith('.docx');
if (isValidType) {
setTemplateFile(file);
console.log('【合同模板上传】有效文件:', file.name);
} else {
messageService.error('只支持PDF、Word格式的文件', {
messageService.error('只支持.pdf、.docx格式的文件', {
title: '文件类型错误',
confirmText: '确定',
cancelText: '',
@@ -1584,7 +1603,7 @@ export default function DocumentsIndex() {
ID: <span className="font-medium">{selectedDocumentId}</span>
</p>
<p className="text-xs text-gray-500 mt-1">
PDFWordZIPRAR格式ZIP/RAR内仅合并其中的PDF文件
.pdf.docxZIPRAR格式ZIP/RAR内需要保证文件格式一致
</p>
</div>
@@ -1732,7 +1751,7 @@ export default function DocumentsIndex() {
ID: <span className="font-medium">{selectedDocumentId}</span>
</p>
<p className="text-xs text-gray-500 mt-1">
PDFWord格式
.pdf.docx格式
</p>
</div>
@@ -1744,7 +1763,7 @@ export default function DocumentsIndex() {
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-gray-400 transition-colors">
<input
type="file"
accept=".pdf,.doc,.docx"
accept=".pdf,.docx"
onChange={(e) => e.target.files && handleTemplateFileSelected(e.target.files)}
className="hidden"
id="template-file-input"
@@ -1752,7 +1771,7 @@ export default function DocumentsIndex() {
<label htmlFor="template-file-input" className="cursor-pointer">
<i className="ri-file-copy-line text-3xl text-gray-400 mb-2 block"></i>
<p className="text-sm text-gray-600"></p>
<p className="text-xs text-gray-500 mt-1">PDFWord格式</p>
<p className="text-xs text-gray-500 mt-1">.pdf.docx格式</p>
</label>
</div>
{templateFile && (
+128 -34
View File
@@ -35,7 +35,7 @@ export function links() {
// 面包屑导航
export const handle = {
breadcrumb: "文档列表",
breadcrumb: "文档上传",
previousRoute: {
title: "文档列表",
to: "/documents/list"
@@ -647,7 +647,7 @@ export default function FilesUpload() {
if (hasInvalidFiles) {
// 显示错误提示
messageService.error('只支持PDF、Word格式的文件', {
messageService.error('只支持.pdf、.docx格式的文件', {
title: '文件类型错误',
confirmText: '确定',
cancelText: '',
@@ -742,7 +742,7 @@ export default function FilesUpload() {
if (hasInvalidFiles) {
// 显示错误提示
console.error('【调试-handleContractMainFilesSelected】存在无效的文件类型');
messageService.error('只支持PDF、Word格式的文件', {
messageService.error('只支持.pdf、.docx格式的文件', {
title: '文件类型错误',
confirmText: '确定',
cancelText: '',
@@ -799,7 +799,7 @@ export default function FilesUpload() {
if (hasInvalidFiles) {
// 显示错误提示
console.error('【调试-handleContractAttachmentFilesSelected】存在无效的文件类型');
messageService.error('只支持PDF、Word格式的文件', {
messageService.error('只支持.pdf、.docx格式的文件', {
title: '文件类型错误',
confirmText: '确定',
cancelText: '',
@@ -856,7 +856,7 @@ export default function FilesUpload() {
if (hasInvalidFiles) {
// 显示错误提示
console.error('【调试-handleContractTemplateFilesSelected】存在无效的文件类型');
messageService.error('只支持PDF、Word格式的文件', {
messageService.error('只支持.pdf、.docx格式的文件', {
title: '文件类型错误',
confirmText: '确定',
cancelText: '',
@@ -883,21 +883,34 @@ export default function FilesUpload() {
const handleAttachmentFilesSelected = (files: FileList) => {
try {
console.log('【附件追加】开始处理附件文件选择, 文件数量:', files.length);
if (files.length > 0) {
// 检查主文件类型
const selectedDocument = queueFiles.find(doc => doc.id === selectedDocumentId);
const isMainFileDocx = selectedDocument?.path.toLowerCase().endsWith('.docx');
// 验证文件类型,支持PDF、Word、ZIP、RAR
const validFiles: File[] = [];
let hasInvalidFiles = false;
let hasPdfForDocx = false;
Array.from(files).forEach(file => {
const fileName = file.name.toLowerCase();
const isValidType =
file.type === 'application/pdf' || fileName.endsWith('.pdf') ||
const isPdf = file.type === 'application/pdf' || fileName.endsWith('.pdf');
const isValidType =
isPdf ||
file.type === 'application/msword' || fileName.endsWith('.doc') ||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || fileName.endsWith('.docx') ||
file.type === 'application/zip' || fileName.endsWith('.zip') ||
file.type === 'application/x-rar-compressed' || fileName.endsWith('.rar');
// 如果主文件是docx,不允许上传pdf附件
if (isMainFileDocx && isPdf) {
hasPdfForDocx = true;
console.error(`【附件追加】主文件为DOCX格式时不允许上传PDF附件: ${file.name}`);
return;
}
if (isValidType) {
validFiles.push(file);
} else {
@@ -905,15 +918,21 @@ export default function FilesUpload() {
console.error(`【附件追加】无效的文件类型: ${file.name}, 类型: ${file.type}`);
}
});
if (hasInvalidFiles) {
if (hasPdfForDocx) {
messageService.error('主文件为DOCX格式时,附件不可以是PDF格式', {
title: '文件类型限制',
confirmText: '确定',
cancelText: '',
});
} else if (hasInvalidFiles) {
messageService.error('只支持PDF、Word、ZIP、RAR格式的文件', {
title: '文件类型错误',
confirmText: '确定',
cancelText: '',
});
}
if (validFiles.length > 0) {
setAttachmentFiles(validFiles);
console.log('【附件追加】有效文件数量:', validFiles.length);
@@ -984,7 +1003,7 @@ export default function FilesUpload() {
setTemplateFile(file);
console.log('【合同模板上传】有效文件:', file.name);
} else {
messageService.error('只支持PDF、Word格式的文件', {
messageService.error('只支持.pdf、.docx格式的文件', {
title: '文件类型错误',
confirmText: '确定',
cancelText: '',
@@ -1166,6 +1185,11 @@ export default function FilesUpload() {
clearInterval(uploadProgressIntervalRef.current);
uploadProgressIntervalRef.current = null;
}
// 清空合同模板文件缓存
setContractTemplateFiles([]);
console.log('【合同上传失败】已清空合同模板文件缓存');
resetUpload();
}
};
@@ -1304,7 +1328,7 @@ export default function FilesUpload() {
if (invalidFiles.length > 0) {
console.error('【调试-startUpload】文件类型验证失败:', invalidFiles.map(f => f.name));
throw new Error('只支持PDF、Word格式的文件');
throw new Error('只支持.pdf、.docx格式的文件');
}
setUploadStage("uploading");
@@ -2235,7 +2259,7 @@ export default function FilesUpload() {
ref={uploadAreaRef}
onFilesSelected={handleFilesSelected}
multiple={true}
accept=".pdf,.doc,.docx"
accept=".pdf,.docx"
tipText="支持单个或多个文件上传,文件格式:PDF/Word"
shouldPreventFileSelect={!fileType}
/>
@@ -2243,12 +2267,29 @@ export default function FilesUpload() {
// 合同文件上传区域 - 三区域布局
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<h4 className="font-medium mb-2"></h4>
<UploadArea
<div className="flex items-center gap-4 mb-2">
<h4 className="font-medium"></h4>
{contractMainFiles.length > 0 && (
<button
type="button"
onClick={() => {
setContractMainFiles([]);
if (contractMainFileRef.current) {
contractMainFileRef.current.resetFileInput();
}
}}
className="text-red-500 hover:text-red-700 transition-colors"
title="清空已选择的主文件"
>
<i className="ri-delete-bin-line"></i>
</button>
)}
</div>
<UploadArea
onFilesSelected={handleContractMainFilesSelected}
ref={contractMainFileRef}
multiple={false}
accept=".pdf,.doc,.docx"
accept=".pdf,.docx"
tipText="请上传合同主文件,格式:PDF/Word"
mainText="上传合同主文件"
buttonText="选择主文件"
@@ -2257,18 +2298,35 @@ export default function FilesUpload() {
/>
{contractMainFiles.length > 0 && (
<div className="mt-2 text-sm text-green-600">
<i className="ri-checkbox-circle-line"></i>
<i className="ri-checkbox-circle-line"></i>
: <span className="font-medium">{contractMainFiles[0].name}</span>
</div>
)}
</div>
<div>
<h4 className="font-medium mb-2"></h4>
<UploadArea
<div className="flex items-center gap-4 mb-2">
<h4 className="font-medium"></h4>
{contractAttachmentFiles.length > 0 && (
<button
type="button"
onClick={() => {
setContractAttachmentFiles([]);
if (contractAttachmentFileRef.current) {
contractAttachmentFileRef.current.resetFileInput();
}
}}
className="text-red-500 hover:text-red-700 transition-colors"
title="清空已选择的附件"
>
<i className="ri-delete-bin-line"></i>
</button>
)}
</div>
<UploadArea
onFilesSelected={handleContractAttachmentFilesSelected}
ref={contractAttachmentFileRef}
multiple={false}
accept=".pdf,.doc,.docx"
accept=".pdf,.docx"
tipText="请上传合同附件,格式:PDF/Word"
mainText="上传合同附件"
buttonText="选择附件"
@@ -2277,7 +2335,7 @@ export default function FilesUpload() {
/>
{contractAttachmentFiles.length > 0 && (
<div className="mt-2 text-sm text-green-600">
<i className="ri-checkbox-circle-line"></i>
<i className="ri-checkbox-circle-line"></i>
: {contractAttachmentFiles.map((file, index) => (
<span key={index} className="font-medium">{file.name}</span>
))}
@@ -2285,11 +2343,25 @@ export default function FilesUpload() {
)}
</div>
<div>
<h4 className="font-medium mb-2"></h4>
<UploadArea
<div className="flex gap-4 items-center mb-2">
<h4 className="font-medium"></h4>
{contractTemplateFiles.length > 0 && (
<button
type="button"
onClick={() => {
setContractTemplateFiles([]);
}}
className="text-red-500 hover:text-red-700 transition-colors"
title="清空已选择的模板"
>
<i className="ri-delete-bin-line"></i>
</button>
)}
</div>
<UploadArea
onFilesSelected={handleContractTemplateFilesSelected}
multiple={false}
accept=".pdf,.doc,.docx"
accept=".pdf,.docx"
tipText="请上传合同模板,格式:PDF/Word"
mainText="上传合同模板"
buttonText="选择模板"
@@ -2298,7 +2370,7 @@ export default function FilesUpload() {
/>
{contractTemplateFiles.length > 0 && (
<div className="mt-2 text-sm text-green-600">
<i className="ri-checkbox-circle-line"></i>
<i className="ri-checkbox-circle-line"></i>
: <span className="font-medium">{contractTemplateFiles[0].name}</span>
</div>
)}
@@ -2521,7 +2593,19 @@ export default function FilesUpload() {
{/* 附件追加模态框 */}
{showAttachmentUpload && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]"
onClick={(e) => {
// 点击蒙层关闭模态框
if (e.target === e.currentTarget) {
setShowAttachmentUpload(false);
setSelectedDocumentId(null);
setAttachmentFiles([]);
setAttachmentRemark("");
setAttachmentMergeMode('overwrite');
}
}}
>
<div className="bg-white rounded-lg p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold"></h3>
@@ -2557,7 +2641,7 @@ export default function FilesUpload() {
<UploadArea
onFilesSelected={handleAttachmentFilesSelected}
multiple={true}
accept=".pdf,.doc,.docx,.zip,.rar"
accept=".pdf,.docx,.zip,.rar"
tipText="支持PDF、Word、ZIP、RAR格式,可多选"
mainText="选择附件文件"
buttonText="选择文件"
@@ -2655,7 +2739,17 @@ export default function FilesUpload() {
{/* 合同模板上传模态框 */}
{showTemplateUpload && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]"
onClick={(e) => {
// 点击蒙层关闭模态框
if (e.target === e.currentTarget) {
setShowTemplateUpload(false);
setSelectedDocumentId(null);
setTemplateFile(null);
}
}}
>
<div className="bg-white rounded-lg p-6 w-full max-w-lg mx-4">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold"></h3>
@@ -2678,7 +2772,7 @@ export default function FilesUpload() {
ID: <span className="font-medium">{selectedDocumentId}</span>
</p>
<p className="text-xs text-gray-500 mt-1">
PDFWord格式
.pdf.docx格式
</p>
</div>
@@ -2690,8 +2784,8 @@ export default function FilesUpload() {
<UploadArea
onFilesSelected={handleTemplateFileSelected}
multiple={false}
accept=".pdf,.doc,.docx"
tipText="支持PDF、Word格式"
accept=".pdf,.docx"
tipText="支持.pdf、.docx格式"
mainText="选择模板文件"
buttonText="选择文件"
icon="ri-file-copy-line"
+58 -7
View File
@@ -8,14 +8,25 @@
* - 后续可扩展文件上传功能
*/
import { type MetaFunction } from "@remix-run/node";
import { type MetaFunction, type ClientLoaderFunctionArgs } 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';
// 动态导入 Monaco Editor(仅客户端)
let DiffEditor: any = null;
let editor: any = null;
if (typeof window !== 'undefined') {
import('@monaco-editor/react').then((mod) => {
DiffEditor = mod.DiffEditor;
});
import('monaco-editor').then((mod) => {
editor = mod.editor;
});
}
// 设置 PDF.js worker(与 pdf-demo.tsx 相同)
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
@@ -129,9 +140,10 @@ const CONTRACT_B = `中国烟草合同(修订版本)
export default function MonacoDemoPage() {
const [originalText, setOriginalText] = useState(CONTRACT_A);
const [modifiedText, setModifiedText] = useState(CONTRACT_B);
const diffEditorRef = useRef<editor.IStandaloneDiffEditor | null>(null);
const diffEditorRef = useRef<any>(null);
const [diffCount, setDiffCount] = useState<number>(0);
const [currentDiff, setCurrentDiff] = useState<number>(0);
const [editorLoaded, setEditorLoaded] = useState(false);
// 文档相关状态
// 默认使用的测试文档路径(相对路径)
@@ -297,9 +309,23 @@ export default function MonacoDemoPage() {
}
};
// 检查 Monaco Editor 是否已加载
useEffect(() => {
if (typeof window !== 'undefined') {
Promise.all([
import('@monaco-editor/react'),
import('monaco-editor')
]).then(([reactMod, editorMod]) => {
DiffEditor = reactMod.DiffEditor;
editor = editorMod.editor;
setEditorLoaded(true);
});
}
}, []);
// Monaco Editor 挂载后的回调
const handleEditorDidMount = (editor: editor.IStandaloneDiffEditor) => {
diffEditorRef.current = editor;
const handleEditorDidMount = (editorInstance: any) => {
diffEditorRef.current = editorInstance;
// 获取差异数量
const lineChanges = editor.getLineChanges();
@@ -670,7 +696,8 @@ export default function MonacoDemoPage() {
{/* Diff Editor 主体 */}
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
<DiffEditor
{editorLoaded && DiffEditor ? (
<DiffEditor
height="100%"
language="plaintext"
original={originalText}
@@ -699,6 +726,30 @@ export default function MonacoDemoPage() {
diffAlgorithm: 'advanced', // 使用高级差异算法
}}
/>
) : (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
backgroundColor: '#f5f5f5'
}}>
<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' }}>
Monaco Editor...
</div>
</div>
</div>
)}
{/* 文档加载中的遮罩层 */}
{(isLoadingDoc1 || isLoadingDoc2) && (
+25 -1
View File
@@ -315,6 +315,11 @@ export default function ReviewDetails() {
newStatus: string;
message: string;
} | null>(null);
const [aiSuggestionReplace, setAiSuggestionReplace] = useState<{
searchText: string;
replaceText: string;
pageNumber: number;
} | undefined>(undefined);
// 🐛 调试:打印 loader 返回的完整数据到浏览器控制台
useEffect(() => {
@@ -432,7 +437,22 @@ export default function ReviewDetails() {
setHighlightValue(value);
}
};
// 处理AI建议替换
const handleAiSuggestionReplace = (searchText: string, replaceText: string, pageNumber: number) => {
console.log('[Reviews] AI建议替换:', { searchText, replaceText, pageNumber });
// 设置替换参数,触发 CollaboraViewer 的搜索替换
setAiSuggestionReplace({
searchText,
replaceText,
pageNumber
});
// 短暂延迟后清除参数,以便下次可以重新触发
setTimeout(() => {
setAiSuggestionReplace(undefined);
}, 1000);
};
// 刷新评审数据
// async function refreshReviewData(documentId: string) {
// // 设置加载状态
@@ -781,6 +801,7 @@ export default function ReviewDetails() {
charPositions={charPositions}
highlightValue={highlightValue}
userInfo={loaderData.userInfo}
aiSuggestionReplace={aiSuggestionReplace}
/>
);
})()}
@@ -788,12 +809,15 @@ export default function ReviewDetails() {
{/* 右侧:评查结果 */}
<div className="w-full lg:w-[35%]">
{/* {JSON.stringify(reviewData.fileInfo.fileFormat)} */}
<ReviewPointsList
reviewPoints={reviewData.reviewPoints}
statistics={reviewData.statistics}
activeReviewPointResultId={activeReviewPointResultId}
onReviewPointSelect={handleReviewPointSelect}
onStatusChange={handleReviewPointStatusChange}
fileFormat={reviewData.fileInfo.fileFormat}
onAiSuggestionReplace={handleAiSuggestionReplace}
/>
</div>
</div>