feat: 1. 接入CollaboraViewer选中的高亮效果,清除高亮功能,页面销毁自动清除高亮。

2. 合同模板对比接入monaco editor的效果。
3. 添加交叉评查的案卷类型的数据查询。

fix: 1. 修复文档列表的打开模态框蒙板层显示效果。
This commit is contained in:
2025-11-30 19:33:05 +08:00
parent fb67f138dc
commit 4fcc92a381
14 changed files with 1263 additions and 286 deletions
+60 -4
View File
@@ -1,11 +1,11 @@
import { API_BASE_URL } from '../../config/api-config';
import { postgrestPut } from '../postgrest-client';
import { postgrestPut, postgrestGet } from '../postgrest-client';
import axios from 'axios';
// 交叉评查任务状态枚举
export enum CrossCheckingTaskStatus {
PENDING = 'pending',
IN_PROGRESS = 'in_progress',
IN_PROGRESS = 'in_progress',
COMPLETED = 'completed'
}
@@ -21,6 +21,13 @@ export enum CrossCheckingDocType {
PERMIT = 'permit' // 行政许可
}
// 文档类型接口(用于交叉评查案卷类型选项)
export interface DocumentType {
id: number;
name: string;
evaluation_point_groups_ids?: number[];
}
// 交叉评查任务接口
export interface CrossCheckingTask {
id: number;
@@ -491,11 +498,11 @@ export async function updateDocumentAuditStatus(id: string, auditStatus: number,
},
frontendJWT
);
if (response.error) {
return { error: response.error, status: response.status };
}
return { success: true };
} catch (error) {
console.error('更新文件审核状态失败:', error);
@@ -504,4 +511,53 @@ export async function updateDocumentAuditStatus(id: string, auditStatus: number,
status: 500
};
}
}
/**
* 获取可用于交叉评查的文档类型列表
* 条件:evaluation_point_groups_ids 不为空
* @param jwtToken JWT token
* @returns 文档类型列表
*/
export async function getCrossCheckingDocumentTypes(jwtToken?: string): Promise<ApiResponse<DocumentType[]>> {
try {
console.log('[getCrossCheckingDocumentTypes] 开始获取交叉评查文档类型');
const response = await postgrestGet<DocumentType>('document_types',{
select: 'id,name,evaluation_point_groups_ids',
filter: {
evaluation_point_groups_ids: 'not.is.null'
},
token: jwtToken
});
if (response.error) {
console.error('[getCrossCheckingDocumentTypes] 获取失败:', response.error);
return {
success: false,
error: response.error
};
}
// 进一步过滤,确保 evaluation_point_groups_ids 是非空数组
const dataArray = Array.isArray(response.data) ? response.data : [];
const filteredData = dataArray.filter(
(item: DocumentType) => item.evaluation_point_groups_ids &&
Array.isArray(item.evaluation_point_groups_ids) &&
item.evaluation_point_groups_ids.length > 0
);
console.log('[getCrossCheckingDocumentTypes] 获取成功,共', filteredData.length, '个文档类型');
return {
success: true,
data: filteredData
};
} catch (error) {
console.error('[getCrossCheckingDocumentTypes] 获取交叉评查文档类型失败:', error);
return {
success: false,
error: error instanceof Error ? error.message : '获取文档类型失败'
};
}
}
+9 -9
View File
@@ -42,7 +42,7 @@ export async function callPythonScript(
const verbose = options?.verbose ?? true;
if (verbose) {
console.log('[CallCustomScript] 调用 Python 脚本:', { scriptFile, functionName, args });
// console.log('[CallCustomScript] 调用 Python 脚本:', { scriptFile, functionName, args });
}
return new Promise((resolve, reject) => {
@@ -72,7 +72,7 @@ export async function callPythonScript(
cleanup();
if (verbose) {
console.log('[CallCustomScript] 收到 Python 脚本响应:', data);
// console.log('[CallCustomScript] 收到 Python 脚本响应:', data);
}
const result = parseScriptResponse(data, verbose);
@@ -107,7 +107,7 @@ export async function callPythonScript(
};
if (verbose) {
console.log('[CallCustomScript] 发送 PostMessage:', message);
// console.log('[CallCustomScript] 发送 PostMessage:', message);
}
iframeWindow.postMessage(JSON.stringify(message), '*');
@@ -130,12 +130,12 @@ function parseScriptResponse(data: PostMessageResponse, verbose: boolean): Scrip
}
if (verbose) {
console.log('[CallCustomScript] 解析结果:', {
commandName: values.commandName,
unoSuccess: values.success,
resultRaw: values.result,
resultExtracted: resultValue,
});
// console.log('[CallCustomScript] 解析结果:', {
// commandName: values.commandName,
// unoSuccess: values.success,
// resultRaw: values.result,
// resultExtracted: resultValue,
// });
}
if (values.success === false) {
+110 -4
View File
@@ -10,11 +10,11 @@
* @encoding UTF-8
*/
import { useRef, forwardRef, useImperativeHandle, useState } from 'react';
import { useRef, forwardRef, useImperativeHandle, useState, useEffect } from 'react';
import type { CollaboraViewerProps, CollaboraViewerHandle } from './types';
import { useCollaboraConfig, useDocumentReady, useCollaboraUnoCommands } from './hooks';
import { sendUnoCommand } from './Uno';
import { highlightText } from './lib/Highlightselecttext';
import { highlightText as performTextHighlight } from './lib/Highlightselecttext';
import { clearHighlights } from './lib/ClearHighlight';
import {
unoScrollToTop,
@@ -41,10 +41,14 @@ export const CollaboraViewer = forwardRef<CollaboraViewerHandle, CollaboraViewer
mode = 'view',
userId = 'guest',
userName = '',
targetPage,
highlightText,
},
ref
) {
const iframeRef = useRef<HTMLIFrameElement>(null);
// 保存 iframe 的 contentWindow 引用,用于组件卸载时清除高亮
const iframeWindowRef = useRef<Window | null>(null);
// 高亮测试面板状态
const [highlightTextInput, setHighlightTextInput] = useState('');
@@ -89,17 +93,119 @@ export const CollaboraViewer = forwardRef<CollaboraViewerHandle, CollaboraViewer
// 2. 监听文档加载状态
const { isDocumentLoaded } = useDocumentReady(iframeRef);
// 2.5. 保存 iframe window 引用并在文档加载时清除所有高亮
useEffect(() => {
if (isDocumentLoaded && iframeRef.current?.contentWindow) {
iframeWindowRef.current = iframeRef.current.contentWindow;
console.log('[CollaboraViewer] 已保存 iframe window 引用');
// 🔥 文档加载完成后主动清除一次高亮(防止缓存的高亮状态)
console.log('[CollaboraViewer] 🧹 文档加载完成,清除可能存在的缓存高亮');
clearHighlights(iframeRef.current.contentWindow, {
color: 16776960,
timeout: 5000,
}).then((result) => {
if (result.count && result.count > 0) {
console.log(`[CollaboraViewer] ✓ 清除了 ${result.count} 个缓存的高亮区域`);
} else {
console.log('[CollaboraViewer] ✓ 文档无缓存高亮,已确认干净');
}
}).catch(error => {
console.warn('[CollaboraViewer] ⚠️ 清除缓存高亮失败:', error);
});
}
}, [isDocumentLoaded]);
// 3. UNO 命令封装
const unoCommands = useCollaboraUnoCommands(iframeRef);
// 4. 暴露接口给父组件
// 4. 暴露接口给父组件(包括清除高亮方法)
useImperativeHandle(ref, () => ({
unoCommands,
isReady: isDocumentLoaded,
mode,
getIframeWindow: () => iframeRef.current?.contentWindow || null,
clearAllHighlights: async () => {
const savedWindow = iframeWindowRef.current || iframeRef.current?.contentWindow;
if (savedWindow) {
console.log('[CollaboraViewer] 🧹 父组件调用清除高亮');
await clearHighlights(savedWindow, {
color: 16776960,
timeout: 5000,
});
console.log('[CollaboraViewer] ✓ 清除高亮完成');
} else {
console.warn('[CollaboraViewer] ⚠️ 无法清除高亮:iframe window 不可用');
}
},
}), [unoCommands, isDocumentLoaded, mode]);
// 5. 监听 targetPage 和 highlightText 变化,自动跳转并高亮
useEffect(() => {
// 如果文档未加载完成,不执行跳转和高亮
if (!isDocumentLoaded || !iframeRef.current?.contentWindow) {
return;
}
// 如果有高亮文本,执行高亮操作
if (highlightText && highlightText.trim() !== '') {
const performHighlight = async () => {
try {
const iframeWindow = iframeRef.current!.contentWindow!;
const textToHighlight = highlightText.trim();
// 🔥 在高亮新内容之前,先清除之前的所有高亮
console.log('[CollaboraViewer] 清除旧高亮...');
await clearHighlights(iframeWindow, {
color: 16776960, // 黄色
timeout: 5000,
});
// 短暂延迟后执行新高亮,确保清除操作完成
await new Promise(resolve => setTimeout(resolve, 100));
// 执行新高亮
await performTextHighlight(
iframeWindow,
textToHighlight,
{ page: targetPage }
);
console.log(`[CollaboraViewer] 已高亮文本: "${textToHighlight}"${targetPage ? ` (第${targetPage}页)` : ''}`);
} catch (error) {
console.error('[CollaboraViewer] 高亮失败:', error);
}
};
performHighlight();
}
}, [targetPage, highlightText, isDocumentLoaded]);
// 6. 组件销毁时清除所有高亮(使用保存的 window 引用)
useEffect(() => {
// 返回清理函数,在组件卸载时执行
return () => {
const savedWindow = iframeWindowRef.current;
if (savedWindow) {
console.log('[CollaboraViewer] 🔥 组件即将销毁,立即清除所有高亮');
// 立即触发清除操作,不等待异步完成
// 使用 void 关键字表示我们不关心 Promise 的结果
void clearHighlights(savedWindow, {
color: 16776960, // 黄色
timeout: 3000,
}).then(() => {
console.log('[CollaboraViewer] ✓ 组件销毁时高亮清除成功');
}).catch(error => {
console.error('[CollaboraViewer] ✗ 组件销毁时清除高亮失败:', error);
});
// 清空引用
iframeWindowRef.current = null;
} else {
console.warn('[CollaboraViewer] ⚠️ 组件销毁时未找到保存的 window 引用');
}
};
}, []);
// 加载中状态
if (loading) {
@@ -185,7 +291,7 @@ export const CollaboraViewer = forwardRef<CollaboraViewerHandle, CollaboraViewer
return;
}
await highlightText(
await performTextHighlight(
iframeRef.current.contentWindow,
highlightTextInput.trim(),
{ page }
+6
View File
@@ -13,6 +13,12 @@ export {
unoScrollToTop
} from './navigation';
// 缩放功能
export {
unoZoomIn,
unoZoomOut,
unoSetZoom
} from './zoom';
// 页数信息
export {
+37
View File
@@ -0,0 +1,37 @@
/**
* Collabora Online 缩放功能
*
* @encoding UTF-8
*/
import { sendUnoCommand } from '../Uno';
/**
* 放大文档
* @param iframeWindow - iframe 的 contentWindow
*/
export function unoZoomIn(iframeWindow: Window): void {
sendUnoCommand(iframeWindow, '.uno:ZoomPlus');
}
/**
* 缩小文档
* @param iframeWindow - iframe 的 contentWindow
*/
export function unoZoomOut(iframeWindow: Window): void {
sendUnoCommand(iframeWindow, '.uno:ZoomMinus');
}
/**
* 设置缩放比例
* @param iframeWindow - iframe 的 contentWindow
* @param percentage - 缩放比例(例如:100 表示 100%)
*/
export function unoSetZoom(iframeWindow: Window, percentage: number): void {
sendUnoCommand(iframeWindow, '.uno:Zoom', {
Zoom: {
type: 'long',
value: percentage,
},
});
}
+6
View File
@@ -34,6 +34,10 @@ export interface CollaboraViewerProps {
userId?: string;
/** 用户名称 */
userName?: string;
/** 目标页码(用于自动跳转和高亮) */
targetPage?: number;
/** 要高亮的文本内容 */
highlightText?: string;
}
/**
@@ -50,4 +54,6 @@ export interface CollaboraViewerHandle {
mode: 'view' | 'edit';
/** 获取 iframe 的 contentWindow (用于发送 PostMessage) */
getIframeWindow: () => Window | null;
/** 清除所有高亮 */
clearAllHighlights: () => Promise<void>;
}
+144 -170
View File
@@ -52,7 +52,8 @@ interface FilePreviewProps {
reviewPoints?: ReviewPoint[]; // 设为可选
activeReviewPointResultId: string | null;
targetPage?: number; // 新增目标页码参数
charPositions?: Array<{ box: number[][], char: string, score: number }>; // 字符位置信息
charPositions?: Array<{ box: number[][], char: string, score: number }>; // 字符位置信息(仅用于PDF
highlightValue?: string; // 高亮文本值(用于DOCX
isStructuredView?: boolean; // 是否显示结构化视图
userInfo?: {
sub: string;
@@ -61,7 +62,7 @@ interface FilePreviewProps {
}
// export function FilePreview({ fileContent, reviewPoints, activeReviewPointResultId, targetPage }: FilePreviewProps) {
export function FilePreview({ fileContent, activeReviewPointResultId, targetPage, charPositions, isStructuredView = false, userInfo }: FilePreviewProps) {
export function FilePreview({ fileContent, activeReviewPointResultId, targetPage, charPositions, highlightValue, isStructuredView = false, userInfo }: FilePreviewProps) {
// 获取文件类型
const real_path = fileContent.path || fileContent.template_contract_path || '';
const fileExtension = real_path.split('.').pop()?.toLowerCase();
@@ -73,16 +74,51 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage
const contentRef = useRef<HTMLDivElement>(null);
const collaboraViewerRef = useRef<CollaboraViewerHandle>(null);
const prevTargetPageRef = useRef<number | undefined>(undefined);
const lastMousePosRef = useRef({ x: 0, y: 0 });
// States
const [numPages, setNumPages] = useState<number | null>(null);
const [pageInputValue, setPageInputValue] = useState<string>('');
const [dragMode, setDragMode] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [dragCursor, setDragCursor] = useState('default');
const [isDocumentLoading, setIsDocumentLoading] = useState<boolean>(true); // 文档加载状态
const [isScrollingToTop, setIsScrollingToTop] = useState<boolean>(false); // 返回顶部loading状态
const [isClearingHighlights, setIsClearingHighlights] = useState<boolean>(false); // 清除高亮loading状态
// ✅ 将所有useEffect移到条件return之前
// 清除高亮:在组件卸载或文档路径变化时
useEffect(() => {
// 返回清理函数
return () => {
if (isDocx && collaboraViewerRef.current?.isReady) {
console.log('[FilePreview] 🔥 文档切换,调用 clearAllHighlights');
// 调用暴露的清除方法
collaboraViewerRef.current.clearAllHighlights().catch(error => {
console.error('[FilePreview] ✗ 清除高亮失败:', error);
});
}
};
}, [real_path, isDocx]); // 当文档路径变化时,清除旧文档的高亮
// 监听文档加载状态
useEffect(() => {
if (!isDocx) {
setIsDocumentLoading(false); // 非DOCX文件直接设为已加载
return;
}
// DOCX文件需要等待 Collabora 准备就绪
setIsDocumentLoading(true);
const checkInterval = setInterval(() => {
if (collaboraViewerRef.current?.isReady) {
setIsDocumentLoading(false);
clearInterval(checkInterval);
}
}, 200);
return () => {
clearInterval(checkInterval);
};
}, [isDocx, real_path]); // 当文档路径变化时重新检测
// DOCX 页数获取: 使用 requestPageInfo 方法
useEffect(() => {
if (!isDocx || isPdf) return; // PDF文件不需要执行
@@ -123,31 +159,6 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage
};
}, [isDocx, isPdf]);
// 监听鼠标离开窗口事件
useEffect(() => {
if (isPdf) return; // PDF不需要拖拽功能
const handleMouseLeave = () => {
if (dragMode && isDragging) {
setIsDragging(false);
setDragCursor('grab');
}
};
const handleMouseUp = () => {
if (!dragMode) return;
setIsDragging(false);
setDragCursor('grab');
};
document.addEventListener('mouseleave', handleMouseLeave);
document.addEventListener('mouseup', handleMouseUp as EventListener);
return () => {
document.removeEventListener('mouseleave', handleMouseLeave);
document.removeEventListener('mouseup', handleMouseUp as EventListener);
};
}, [isDragging, dragMode, isPdf]);
// 处理页面跳转
useEffect(() => {
@@ -214,75 +225,6 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage
// DOCX 和其他文件类型继续使用原有逻辑
// 放大文档(仅用于 DOCX
const handleZoomIn = () => {
if (!collaboraViewerRef.current?.isReady) {
toastService.warning('文档尚未加载完成,请稍候...');
return;
}
collaboraViewerRef.current?.unoCommands.zoomIn();
};
// 缩小文档(仅用于 DOCX
const handleZoomOut = () => {
if (!collaboraViewerRef.current?.isReady) {
toastService.warning('文档尚未加载完成,请稍候...');
return;
}
collaboraViewerRef.current?.unoCommands.zoomOut();
};
// 切换拖拽模式
const toggleDragMode = () => {
setDragMode(prev => !prev);
setDragCursor(prev => prev === 'default' ? 'grab' : 'default');
setIsDragging(false);
};
// 处理拖拽开始
const handleMouseDown = (e: React.MouseEvent<HTMLButtonElement>) => {
if (!dragMode || e.button !== 0) return; // 只在拖拽模式下响应左键点击
// 防止选中文本
e.preventDefault();
// 设置拖拽状态
setIsDragging(true);
setDragCursor('grabbing');
// 记录鼠标初始位置
lastMousePosRef.current = {
x: e.clientX,
y: e.clientY
};
};
// 处理拖拽过程
const handleMouseMove = (e: React.MouseEvent<HTMLButtonElement>) => {
if (!dragMode || !isDragging || !contentRef.current) return;
// 计算鼠标移动距离
const dx = e.clientX - lastMousePosRef.current.x;
const dy = e.clientY - lastMousePosRef.current.y;
// 更新容器滚动位置
contentRef.current.scrollLeft -= dx;
contentRef.current.scrollTop -= dy;
// 更新鼠标位置记录
lastMousePosRef.current = {
x: e.clientX,
y: e.clientY
};
};
// 处理拖拽结束
const handleMouseUp = () => {
if (!dragMode) return;
setIsDragging(false);
setDragCursor('grab');
};
// 获取评查点对应的样式类
// const getHighlightClass = (status: string) => {
@@ -339,12 +281,48 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage
};
// 滚动到顶部(仅用于 DOCX
const handleScrollToTop = () => {
const handleScrollToTop = async () => {
if (!collaboraViewerRef.current?.isReady) {
toastService.warning('文档尚未加载完成,请稍候...');
return;
}
collaboraViewerRef.current?.unoCommands.scrollToTop();
setIsScrollingToTop(true);
try {
await collaboraViewerRef.current?.unoCommands.scrollToTop();
console.log('[FilePreview] 已返回顶部');
} catch (error) {
console.error('[FilePreview] 返回顶部失败:', error);
toastService.error('返回顶部失败');
} finally {
// 延迟500ms后重置loading状态,给用户足够的视觉反馈
setTimeout(() => {
setIsScrollingToTop(false);
}, 500);
}
};
// 清除所有高亮(仅用于 DOCX)
const handleClearAllHighlights = async () => {
if (!collaboraViewerRef.current?.isReady) {
toastService.warning('文档尚未加载完成,请稍候...');
return;
}
setIsClearingHighlights(true);
try {
await collaboraViewerRef.current.clearAllHighlights();
console.log('[FilePreview] 已清除所有高亮');
toastService.success('已清除所有高亮');
} catch (error) {
console.error('[FilePreview] 清除高亮失败:', error);
toastService.error('清除高亮失败');
} finally {
// 延迟500ms后重置loading状态
setTimeout(() => {
setIsClearingHighlights(false);
}, 500);
}
};
// 渲染文档内容
@@ -368,6 +346,10 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage
// 根据文件类型选择不同的渲染方式
// 注意:PDF 文件已在组件开头使用 PdfPreview 组件提前返回
if (fileExtension === 'docx') {
// 使用 highlightValue 作为高亮文本(用户点击评查点时传递的实际文本值)
// 不再从 charPositions 提取,因为 charPositions 是 PDF 特有的坐标信息
const highlightText = highlightValue;
// DOCX文件使用Collabora Online预览
return (
<CollaboraViewer
@@ -376,6 +358,8 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage
mode="edit"
userId={userInfo?.sub || 'guest'}
userName={userInfo?.nick_name || ''}
targetPage={targetPage}
highlightText={highlightText}
/>
);
} else {
@@ -397,47 +381,66 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage
{isStructuredView ? '模板预览' : '文件预览'}
</span>
</div>
<div className="file-preview-actions flex items-center ml-2 min-w-0 flex-1 justify-end overflow-hidden">
<button
className="ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5 flex-shrink-0"
<div className="file-preview-actions flex items-center ml-2 min-w-0 flex-1 justify-end overflow-hidden gap-2">
<button
className={`flex items-center justify-center px-2 py-1 text-xs text-gray-700 bg-white border border-gray-300 rounded transition-colors duration-200 flex-shrink-0 outline-none ${
isScrollingToTop || isDocumentLoading
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-50 hover:border-primary hover:text-primary'
}`}
onClick={handleScrollToTop}
disabled={isScrollingToTop || isDocumentLoading}
title="返回顶部"
>
<i className="ri-arrow-up-double-line"></i>
<span className="ml-1 hidden sm:hidden md:hidden lg:hidden xl:inline truncate max-w-[60px]"></span>
</button>
<button
className="ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5 flex-shrink-0"
onClick={handleZoomIn}
title="放大"
>
<i className="ri-zoom-in-line"></i>
</button>
<button
className="ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5 flex-shrink-0"
onClick={handleZoomOut}
title="缩小"
>
<i className="ri-zoom-out-line"></i>
{isScrollingToTop ? (
<i className="ri-loader-4-line text-sm animate-spin"></i>
) : (
<i className="ri-arrow-up-double-line text-sm"></i>
)}
<span className="ml-1 hidden sm:hidden md:hidden lg:hidden xl:inline whitespace-nowrap">
{isScrollingToTop ? '返回中...' : '返回顶部'}
</span>
</button>
{/* 清除高亮按钮 - 仅在DOCX文档时显示 */}
{isDocx && (
<button
className={`flex items-center justify-center px-2 py-1 text-xs text-white bg-red-500 border border-red-500 rounded transition-colors duration-200 flex-shrink-0 outline-none ${
isClearingHighlights || isDocumentLoading
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-red-600 hover:border-red-600'
}`}
onClick={handleClearAllHighlights}
disabled={isClearingHighlights || isDocumentLoading}
title="清除所有高亮"
>
{isClearingHighlights ? (
<i className="ri-loader-4-line text-sm animate-spin"></i>
) : (
<i className="ri-eraser-line text-sm"></i>
)}
<span className="ml-1 hidden sm:hidden md:hidden lg:hidden xl:inline whitespace-nowrap">
{isClearingHighlights ? '清除中...' : '清除高亮'}
</span>
</button>
)}
{/* 页码跳转控件 */}
<div className="inline-flex items-center ml-2 flex-shrink-0">
<input
type="text"
className="ant-input ant-input-sm py-0 px-1 text-xs max-h-6 leading-5 w-[2.5rem] text-center
focus:outline-none focus:ring-1 focus:ring-green-900"
<div className="inline-flex items-center flex-shrink-0 gap-1">
<input
type="text"
className="w-12 h-7 px-2 text-xs text-center text-gray-700 bg-white border border-gray-300 rounded outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="页码"
value={pageInputValue}
onChange={handlePageInputChange}
onKeyDown={handlePageInputKeyDown}
disabled={isDocumentLoading}
/>
<button
className="ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5 ml-1"
<button
className="flex items-center justify-center w-7 h-7 text-gray-700 bg-white border border-gray-300 rounded hover:bg-gray-50 hover:border-primary hover:text-primary transition-colors duration-200 outline-none disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-white disabled:hover:border-gray-300 disabled:hover:text-gray-700"
onClick={handlePageJump}
disabled={!numPages}
disabled={!numPages || isDocumentLoading}
title="跳转到页面"
>
<i className="ri-arrow-right-line"></i>
<i className="ri-arrow-right-line text-sm"></i>
</button>
{numPages && (
<span className="ml-1 text-xs text-gray-500 hidden sm:hidden md:hidden lg:hidden xl:inline whitespace-nowrap">
@@ -445,66 +448,37 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage
</span>
)}
</div>
<button
className={`ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5 ml-2 flex-shrink-0 ${dragMode ? 'active bg-green-300' : ''}`}
title="切换拖拽模式"
aria-pressed={dragMode}
onClick={toggleDragMode}
>
<i className="ri-drag-move-line"></i>
<span className="ml-1 hidden sm:hidden md:hidden lg:hidden xl:inline truncate max-w-[80px]">
{dragMode ? '(已激活)' : ''}
</span>
</button>
{/* 缩放提示 - 仅在DOCX文档时显示 */}
{isDocx && (
<div className="flex items-center px-2 py-1 text-xs text-gray-600 bg-gray-50 border border-gray-200 rounded flex-shrink-0">
<i className="ri-zoom-in-line text-sm mr-1"></i>
<span className="whitespace-nowrap">Ctrl+</span>
</div>
)}
</div>
</div>
<div
<div
className="file-preview-content"
ref={contentRef}
style={{
style={{
maxHeight: 'calc(100vh - 150px)',
overflowY: 'auto',
overflowX: 'auto',
cursor: dragCursor,
userSelect: dragMode ? 'none' : 'auto', // 拖拽模式下防止文本被选中
}}
>
<button
<div
className="pdf-interactive-container"
aria-label="PDF文档查看区域"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onKeyDown={(e) => {
// 添加键盘导航支持
const scrollAmount = 50;
if (e.key === 'ArrowUp') {
if (contentRef.current) contentRef.current.scrollTop -= scrollAmount;
e.preventDefault();
} else if (e.key === 'ArrowDown') {
if (contentRef.current) contentRef.current.scrollTop += scrollAmount;
e.preventDefault();
} else if (e.key === 'ArrowLeft') {
if (contentRef.current) contentRef.current.scrollLeft -= scrollAmount;
e.preventDefault();
} else if (e.key === 'ArrowRight') {
if (contentRef.current) contentRef.current.scrollLeft += scrollAmount;
e.preventDefault();
}
}}
style={{
position: 'relative',
height: '100%',
width: '100%',
display: 'block',
border: 'none',
background: 'transparent',
textAlign: 'center',
padding: 0
}}
>
{renderDocumentContent()}
</button>
</div>
</div>
</div>
);
+18 -18
View File
@@ -146,7 +146,7 @@ interface ReviewPointsListProps {
reviewPoints: ReviewPoint[];
statistics: Statistics;
activeReviewPointResultId: string | null;
onReviewPointSelect: (id: string, page?: number, charPositions?: CharPosition[]) => void;
onReviewPointSelect: (id: string, page?: number, charPositions?: CharPosition[], value?: string) => void;
onStatusChange: (id: string, editAuditStatusId: string | number, status: string, message: string) => void;
}
@@ -1163,15 +1163,15 @@ export function ReviewPointsList({
onClick={(e) => {
e.stopPropagation();
if (item.data.page) {
console.log('点击了长链条评查点', item.data.char_positions);
console.log('点击了长链条评查点', item.data.char_positions, item.data);
// 假设onReviewPointSelect在作用域内可用
const reviewPointId = reviewPoint.id as string;
if (reviewPointId && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPointId, Number(item.data.page), item.data.char_positions);
onReviewPointSelect(reviewPointId, Number(item.data.page), item.data.char_positions, item.data.value);
}
}
else if(reviewPoint.contentPage && reviewPoint.contentPage[item.field]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[item.field]));
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[item.field]), item.data.char_positions, item.data.value);
}
else{
toastService.error(`没有找到${item.field}对应的索引内容`);
@@ -1248,14 +1248,14 @@ export function ReviewPointsList({
onClick={(e) => {
e.stopPropagation();
if (chain[0].data.page) {
console.log('点击了短链1左', chain[0].data.char_positions)
console.log('点击了短链1左', chain[0].data.char_positions, chain[0].data)
const reviewPointId = reviewPoint.id as string;
if (reviewPointId && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPointId, chain[0].data.page, chain[0].data.char_positions);
onReviewPointSelect(reviewPointId, chain[0].data.page, chain[0].data.char_positions, chain[0].data.value);
}
}
else if(reviewPoint.contentPage && reviewPoint.contentPage[chain[0].field]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[0].field]));
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[0].field]), chain[0].data.char_positions,chain[0].data.value);
}
else{
toastService.error(`没有找到${chain[0].field}对应的索引内容`);
@@ -1275,14 +1275,14 @@ export function ReviewPointsList({
onClick={(e) => {
e.stopPropagation();
if (chain[1].data.page) {
console.log('点击了短链2右', chain[1].data.char_positions)
console.log('点击了短链2右', chain[1].data.char_positions, chain[1].data)
const reviewPointId = reviewPoint.id as string;
if (reviewPointId && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPointId, chain[1].data.page, chain[1].data.char_positions);
onReviewPointSelect(reviewPointId, chain[1].data.page, chain[1].data.char_positions, chain[1].data.value);
}
}
else if(reviewPoint.contentPage && reviewPoint.contentPage[chain[1].field]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[1].field]));
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[1].field]), chain[1].data.char_positions, chain[1].data.value);
}
else{
toastService.error(`没有找到${chain[1].field}对应的索引内容`);
@@ -1419,9 +1419,9 @@ export function ReviewPointsList({
e.stopPropagation();
if (mainTypeValue.page && typeof onReviewPointSelect === 'function') {
console.log("点击了其他评查点", mainTypeValue)
onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), mainTypeValue.char_positions);
onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), mainTypeValue.char_positions, mainTypeValue.value);
}else if(reviewPoint.contentPage && reviewPoint.contentPage[fieldKey]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[fieldKey]));
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[fieldKey]), mainTypeValue.char_positions, mainTypeValue.value);
}else{
toastService.error(`没有找到${fieldKey}对应的索引内容`);
}
@@ -1430,7 +1430,7 @@ export function ReviewPointsList({
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (mainTypeValue.page && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), mainTypeValue.char_positions);
onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), mainTypeValue.char_positions, mainTypeValue.value);
}else{
toastService.error(`没有找到${fieldKey}对应的索引内容`);
}
@@ -1541,10 +1541,10 @@ export function ReviewPointsList({
onClick={(e) => {
e.stopPropagation();
if (value.page && typeof onReviewPointSelect === 'function') {
console.log("点击了大模型的评查点", value.char_positions)
onReviewPointSelect(reviewPoint.id, Number(value.page), value.char_positions);
console.log("点击了大模型的评查点", value.char_positions, value)
onReviewPointSelect(reviewPoint.id, Number(value.page), value.char_positions, value.value);
}else if(reviewPoint.contentPage && reviewPoint.contentPage[key]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[key]));
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[key]), value.char_positions,value.value);
}else{
toastService.error(`没有找到${key}对应的索引内容`);
}
@@ -1554,9 +1554,9 @@ export function ReviewPointsList({
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (value.page && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPoint.id, Number(value.page), value.char_positions);
onReviewPointSelect(reviewPoint.id, Number(value.page), value.char_positions, value.value);
}else if(reviewPoint.contentPage && reviewPoint.contentPage[key]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[key]));
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[key]), value.char_positions,value.value);
}else{
toastService.error(`没有找到${key}对应的索引内容`);
}
+2 -1
View File
@@ -221,6 +221,7 @@ export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfi
<div className="tab-nav w-full flex justify-between">
{/* 评查结果、AI智能分析、文件信息 */}
<div className="flex">
{/* {JSON.stringify(fileInfo)} */}
<button
className={`tab-nav-item ${activeTab === 'preview' ? 'active' : ''}`}
onClick={() => onTabChange('preview')}
@@ -237,7 +238,7 @@ export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfi
>
<i className="ri-lightbulb-line"></i> AI智能分析
</button> */}
{fileInfo.type === '1' && (
{fileInfo.type?.toString().includes('1') && (
<button
className={`tab-nav-item ${activeTab === 'filecompare' ? 'active' : ''}`}
onClick={() => onTabChange('filecompare')}
@@ -0,0 +1,701 @@
/**
* ComparePreview - Document Comparison Preview Component
*
* Features:
* - Compare two documents using Monaco Editor
* - Support PDF and Word (.docx) files
* - Automatic text extraction and line-by-line comparison
* - Navigation between differences
*
* Props:
* - doc1Path: Original document path
* - doc2Path: Comparison document path
*/
import React, { 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';
import { DOCUMENT_URL } from '~/config/api-config';
// Setup PDF.js worker
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
// Document type enum
type DocumentType = 'pdf' | 'docx' | 'unknown';
// PDF type enum
type PdfType = 'text' | 'scanned' | 'unknown';
// PDF info interface
interface PdfInfo {
type: PdfType;
numPages: number;
textLength: number;
confidence: number;
}
// Document info interface
interface DocumentInfo {
fileType: DocumentType;
pdfType?: PdfType;
numPages?: number;
textLength: number;
confidence: number;
}
// Component Props interface
interface ComparePreviewProps {
doc1Path: string;
doc2Path: string;
}
export function ComparePreview({ doc1Path, doc2Path }: ComparePreviewProps): JSX.Element {
// 如果没有模板合同路径,直接返回提示
if (!doc2Path || doc2Path.trim() === '') {
return (
<div style={{
height: '100%',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '40px',
backgroundColor: '#f9f9f9'
}}>
<div style={{
maxWidth: '500px',
textAlign: 'center',
padding: '32px',
backgroundColor: '#fff',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
border: '1px solid #e0e0e0'
}}>
<div style={{
width: '64px',
height: '64px',
margin: '0 auto 24px',
backgroundColor: '#fff3cd',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '3px solid #ffc107'
}}>
<i className="ri-file-warning-line" style={{ fontSize: '32px', color: '#ff9800' }}></i>
</div>
<h3 style={{
fontSize: '20px',
fontWeight: '600',
color: '#333',
marginBottom: '12px'
}}>
</h3>
<p style={{
fontSize: '14px',
color: '#666',
lineHeight: '1.6',
marginBottom: '16px'
}}>
</p>
<div style={{
padding: '12px 16px',
backgroundColor: '#fff3cd',
border: '1px solid #ffc107',
borderRadius: '4px',
fontSize: '13px',
color: '#856404',
lineHeight: '1.5'
}}>
<i className="ri-error-warning-line" style={{ marginRight: '6px' }}></i>
<strong></strong>
</div>
</div>
</div>
);
}
const [originalText, setOriginalText] = useState<string>('');
const [modifiedText, setModifiedText] = useState<string>('');
const diffEditorRef = useRef<editor.IStandaloneDiffEditor | null>(null);
const [diffCount, setDiffCount] = useState<number>(0);
const [currentDiff, setCurrentDiff] = useState<number>(0);
const [doc1Info, setDoc1Info] = useState<DocumentInfo | null>(null);
const [doc2Info, setDoc2Info] = useState<DocumentInfo | null>(null);
const [isLoadingDoc1, setIsLoadingDoc1] = useState(false);
const [isLoadingDoc2, setIsLoadingDoc2] = useState(false);
// Log initial props
// console.log('[ComparePreview] Component initialized with paths:', { doc1Path, doc2Path });
// Detect file type based on file path
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 type detection function
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);
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;
const confidence = Math.min(avgTextPerPage / 500, 1);
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 text extraction function
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========== Page ${i} ==========\n${pageText}\n`;
}
return fullText;
};
// Word document text extraction function
const extractTextFromWord = async (docUrl: string): Promise<string> => {
const response = await fetch(docUrl);
if (!response.ok) {
throw new Error(`Cannot load document: ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
const textResult = await mammoth.extractRawText({ arrayBuffer });
return textResult.value;
};
// Load document and extract text (supports PDF and Word)
const loadDocumentAndExtractText = async (
docPath: string,
setDocInfo: (info: DocumentInfo | null) => void,
setLoading: (loading: boolean) => void,
setTextContent: (text: string) => void
) => {
try {
setLoading(true);
const docUrl = docPath.startsWith('http') ? docPath : `${DOCUMENT_URL}${docPath}`;
// console.log('[ComparePreview] Loading document:', docUrl);
const fileType = detectFileType(docPath);
if (fileType === '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);
// console.log('[ComparePreview] PDF text extracted:', {
// path: docPath,
// textLength: text.length,
// firstChars: text.substring(0, 100)
// });
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') {
const text = await extractTextFromWord(docUrl);
const docInfo: DocumentInfo = {
fileType: 'docx',
textLength: text.length,
confidence: 1.0
};
setDocInfo(docInfo);
setTextContent(text);
// console.log('[ComparePreview] Word text extracted:', {
// path: docPath,
// textLength: text.length,
// firstChars: text.substring(0, 100)
// });
toastService.success(`Word文档加载成功!提取了 ${text.length} 个字符`);
} else {
toastService.error('不支持的文件类型');
setTextContent('');
}
} catch (error) {
console.error('Document loading failed:', error);
toastService.error(`文档加载失败: ${error instanceof Error ? error.message : '未知错误'}`);
setDocInfo(null);
setTextContent('');
} finally {
setLoading(false);
}
};
// Monaco Editor mount callback
const handleEditorDidMount = (editor: editor.IStandaloneDiffEditor) => {
diffEditorRef.current = editor;
// console.log('[ComparePreview] Editor mounted, checking differences...', {
// originalTextLength: originalText.length,
// modifiedTextLength: modifiedText.length
// });
// Polling function to get diff results
// Monaco Editor's diff calculation is asynchronous and may return null initially
let retryCount = 0;
const maxRetries = 20; // Maximum 20 attempts
const retryInterval = 150; // Check every 150ms
const pollForDiffChanges = () => {
retryCount++;
const lineChanges = editor.getLineChanges();
// console.log(`[ComparePreview] Polling attempt ${retryCount}:`, {
// lineChanges: lineChanges ? `${lineChanges.length} changes` : 'null',
// hasLineChanges: lineChanges !== null
// });
// If we got the diff results
if (lineChanges !== null) {
setDiffCount(lineChanges.length);
// console.log(`[ComparePreview] ✅ Successfully got ${lineChanges.length} differences on attempt ${retryCount}`);
// Verify the result makes sense
if (lineChanges.length === 0 && originalText.length > 0 && modifiedText.length > 0) {
const textsAreIdentical = originalText === modifiedText;
// console.log('[ComparePreview] Texts are identical?', textsAreIdentical);
if (textsAreIdentical) {
// console.log('[ComparePreview] ️ Documents are identical - 0 differences is correct');
} else {
console.warn('[ComparePreview] ⚠️ Documents differ but Monaco shows 0 differences');
// console.log('[ComparePreview] Original text sample:', originalText.substring(0, 100));
// console.log('[ComparePreview] Modified text sample:', modifiedText.substring(0, 100));
}
}
return; // Stop polling
}
// If still null and haven't exceeded max retries, try again
if (retryCount < maxRetries) {
setTimeout(pollForDiffChanges, retryInterval);
} else {
console.error(`[ComparePreview] ❌ Failed to get diff results after ${maxRetries} attempts`);
setDiffCount(0);
}
};
// Start polling after a short delay
setTimeout(pollForDiffChanges, 100);
};
// Add highlight flash animation when navigating to a difference
const addFlashHighlight = (editor: editor.ICodeEditor, lineNumber: number) => {
const decorations = editor.deltaDecorations([], [
{
range: new (window as any).monaco.Range(lineNumber, 1, lineNumber, 1),
options: {
isWholeLine: true,
className: 'diff-flash-highlight',
}
}
]);
// Remove the highlight after animation completes
setTimeout(() => {
editor.deltaDecorations(decorations, []);
}, 1000);
};
// Go to next difference
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
});
// Add flash highlight
addFlashHighlight(modifiedEditor, nextChange.modifiedStartLineNumber);
setCurrentDiff(nextIndex);
};
// Go to previous difference
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
});
// Add flash highlight
addFlashHighlight(modifiedEditor, prevChange.modifiedStartLineNumber);
setCurrentDiff(prevIndex);
};
// Load documents on mount
useEffect(() => {
// console.log('[ComparePreview] Doc1 path changed:', doc1Path);
if (doc1Path) {
loadDocumentAndExtractText(doc1Path, setDoc1Info, setIsLoadingDoc1, setOriginalText);
} else {
setOriginalText('');
}
}, [doc1Path]);
useEffect(() => {
// console.log('[ComparePreview] Doc2 path changed:', doc2Path);
if (doc2Path) {
loadDocumentAndExtractText(doc2Path, setDoc2Info, setIsLoadingDoc2, setModifiedText);
} else {
setModifiedText('');
}
}, [doc2Path]);
// Recalculate differences when text changes
useEffect(() => {
// console.log('[ComparePreview] Text changed, recalculating differences...', {
// originalTextLength: originalText.length,
// modifiedTextLength: modifiedText.length,
// hasEditor: !!diffEditorRef.current
// });
const timer = setTimeout(() => {
if (diffEditorRef.current) {
const lineChanges = diffEditorRef.current.getLineChanges();
if (lineChanges) {
setDiffCount(lineChanges.length);
// console.log(`[ComparePreview] Recalculated differences: ${lineChanges.length} found`);
// 如果文本都不为空但差异为0,打印警告
if (lineChanges.length === 0 && originalText.length > 0 && modifiedText.length > 0) {
console.warn('[ComparePreview] Warning: Both texts loaded but 0 differences found!', {
doc1PathsMatch: doc1Path === doc2Path,
originalSample: originalText.substring(0, 50),
modifiedSample: modifiedText.substring(0, 50)
});
}
}
}
}, 100);
return () => clearTimeout(timer);
}, [originalText, modifiedText, doc1Path, doc2Path]);
return (
<div className="compare-preview-container" style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
{/* Character-level diff highlighting styles */}
<style>{`
.compare-preview-container {
height: 100%;
width: 100%;
}
.monaco-diff-editor .char-insert {
background-color: rgba(100, 150, 50, 0.6) !important;
}
.monaco-diff-editor .char-delete {
background-color: rgba(200, 50, 50, 0.5) !important;
}
.monaco-diff-editor .line-insert .char-insert {
background-color: rgba(100, 150, 50, 0.7) !important;
}
.monaco-diff-editor .line-delete .char-delete {
background-color: rgba(200, 50, 50, 0.6) !important;
}
/* Flash highlight animation for navigation */
.diff-flash-highlight {
background-color: rgba(255, 200, 0, 0.3) !important;
animation: flash-pulse 1s ease-in-out;
}
@keyframes flash-pulse {
0%, 100% { background-color: rgba(255, 200, 0, 0); }
50% { background-color: rgba(255, 200, 0, 0.5); }
}
/* Navigation button focus styles */
.nav-diff-button {
padding: 6px 12px;
background-color: #fff;
border: 1px solid #d0d0d0;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s ease;
}
.nav-diff-button:hover:not(:disabled) {
background-color: #f0f0f0;
border-color: #00684a;
}
.nav-diff-button:focus {
outline: none;
border-color: #00684a;
box-shadow: 0 0 0 2px rgba(0, 104, 74, 0.2);
}
.nav-diff-button:active:not(:disabled) {
background-color: #e0e0e0;
}
.nav-diff-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
`}</style>
{/* Toolbar and Info banner - combined in one row */}
<div style={{
padding: '12px 16px',
borderBottom: '1px solid #e0e0e0',
backgroundColor: '#f5f5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '16px'
}}>
{/* Left side: Toolbar controls */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexShrink: 0 }}>
{/* Diff statistics */}
<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>
{/* Navigation buttons */}
<button
className="nav-diff-button"
onClick={goToPreviousDiff}
disabled={diffCount === 0}
>
<i className="ri-arrow-up-line"></i>
</button>
<button
className="nav-diff-button"
onClick={goToNextDiff}
disabled={diffCount === 0}
>
<i className="ri-arrow-down-line"></i>
</button>
</div>
{/* Right side: Info banner */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '6px 12px',
backgroundColor: '#e7f3ff',
border: '1px solid #b3d9ff',
borderRadius: '4px',
fontSize: '13px',
color: '#004085',
flexShrink: 1,
minWidth: 0
}}>
<i className="ri-information-line" style={{ fontSize: '16px', flexShrink: 0 }}></i>
<div style={{ lineHeight: '1.5', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
<strong></strong>
<span style={{ marginLeft: '8px' }}>
<span style={{ color: '#dc3545', fontWeight: 'bold' }}></span> |
<span style={{ color: '#28a745', fontWeight: 'bold', marginLeft: '8px' }}>绿</span> |
<span style={{ color: '#666', fontWeight: 'bold', marginLeft: '8px' }}></span>
</span>
</div>
</div>
</div>
{/* Diff Editor main area */}
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
{/* 只有当两个文本都加载完成后才渲染 Monaco Editor */}
{originalText && modifiedText && !isLoadingDoc1 && !isLoadingDoc2 ? (
<DiffEditor
key={`${doc1Path}-${doc2Path}-${originalText.length}-${modifiedText.length}`}
height="100%"
language="plaintext"
original={originalText}
modified={modifiedText}
onMount={handleEditorDidMount}
theme="vs"
options={{
readOnly: true,
renderSideBySide: true,
ignoreTrimWhitespace: false,
renderWhitespace: 'selection',
fontSize: 14,
lineNumbers: 'on',
minimap: {
enabled: true
},
scrollBeyondLastLine: false,
wordWrap: 'on',
automaticLayout: true,
renderIndicators: true,
diffWordWrap: 'on',
enableSplitViewResizing: true,
diffAlgorithm: 'advanced',
}}
/>
) : (
<div style={{
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#666',
fontSize: '14px'
}}>
{isLoadingDoc1 || isLoadingDoc2 ? (
<span>...</span>
) : (
<span>...</span>
)}
</div>
)}
{/* Loading overlay */}
{(isLoadingDoc1 || isLoadingDoc2) && (
<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' }}>
...
</div>
{isLoadingDoc1 && <div style={{ fontSize: '14px', color: '#666', marginTop: '8px' }}>📄 </div>}
{isLoadingDoc2 && <div style={{ fontSize: '14px', color: '#666', marginTop: '8px' }}>📄 </div>}
</div>
</div>
)}
</div>
{/* Spinner animation */}
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</div>
);
}
+97 -60
View File
@@ -16,6 +16,10 @@ import {
formatFileSize,
batchUploadAndAssignCrossCheckingFiles
} from "~/api/cross-checking/cross-files-upload";
import {
getCrossCheckingDocumentTypes,
type DocumentType
} from "~/api/cross-checking/cross-files";
import {
getOrganizationTree,
convertToTreeData
@@ -125,16 +129,21 @@ const TreeNodeCheckbox: React.FC<{
);
};
/**
* 获取用户会话和前端JWT
* 获取用户会话和前端JWT,以及文档类型列表
*/
export const loader = async ({ request }: LoaderFunctionArgs) => {
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { userInfo, frontendJWT } = await getUserSession(request);
// 获取可用于交叉评查的文档类型列表
const documentTypesResponse = await getCrossCheckingDocumentTypes(frontendJWT);
return Response.json({
userInfo,
frontendJWT
frontendJWT,
documentTypes: documentTypesResponse.success ? documentTypesResponse.data : [],
documentTypesError: documentTypesResponse.error
});
};
@@ -194,10 +203,12 @@ export const action = async ({ request }: ActionFunctionArgs) => {
export default function CrossCheckingUpload() {
// 获取loader数据
const { userInfo, frontendJWT } = useLoaderData<typeof loader>();
// 基础状态
const [caseType, setCaseType] = useState<CaseType>(CaseType.ADMINISTRATIVE_PENALTY);
const { userInfo, frontendJWT, documentTypes, documentTypesError } = useLoaderData<typeof loader>();
// 基础状态 - 使用第一个文档类型的ID作为默认值
const [selectedDocTypeId, setSelectedDocTypeId] = useState<number | null>(
documentTypes && documentTypes.length > 0 ? documentTypes[0].id : null
);
// 步骤状态
const [currentStep, setCurrentStep] = useState(1);
// 任务创建状态
@@ -235,16 +246,17 @@ export default function CrossCheckingUpload() {
// 处理案卷类型切换
const handleCaseTypeChange = (type: CaseType) => {
const handleDocTypeChange = (docTypeId: number) => {
if (isUploading) {
toastService.warning("上传进行中,无法切换案卷类型");
return;
}
setCaseType(type);
setSelectedDocTypeId(docTypeId);
// 清空已选择的文件和重置上传方式
clearAllFiles();
console.log("案卷类型切换为:", type, "typeId:", CASE_TYPE_TO_TYPE_ID[type]);
const selectedType = documentTypes?.find((dt: DocumentType) => dt.id === docTypeId);
console.log("案卷类型切换为:", selectedType?.name, "ID:", docTypeId);
};
// 清空所有文件
@@ -268,7 +280,11 @@ export default function CrossCheckingUpload() {
let hasInvalidFiles = false;
Array.from(files).forEach(file => {
if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
const isPdf = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf');
const isDocx = file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
file.name.toLowerCase().endsWith('.docx');
if (isPdf || isDocx) {
validFiles.push({
id: generateFileId(),
file,
@@ -283,7 +299,7 @@ export default function CrossCheckingUpload() {
});
if (hasInvalidFiles) {
messageService.error('只能上传PDF格式的文件', {
messageService.error('只能上传PDF或DOCX格式的文件', {
title: '文件类型错误',
confirmText: '确定',
});
@@ -413,12 +429,25 @@ export default function CrossCheckingUpload() {
return;
}
// 验证选择了案卷类型
if (!selectedDocTypeId) {
toastService.error("请选择案卷类型");
return;
}
setIsCreatingTask(true);
setIsUploading(true);
try {
// 获取选中的文档类型信息
const selectedDocType = documentTypes?.find((dt: DocumentType) => dt.id === selectedDocTypeId);
if (!selectedDocType) {
toastService.error("无效的案卷类型");
return;
}
// 第一步:上传文件并自动分配任务(新接口)
console.log("开始批量上传文件并分配任务:", filesToUpload.length, "个,案卷类型:", caseType);
console.log("开始批量上传文件并分配任务:", filesToUpload.length, "个,案卷类型:", selectedDocType.name);
// 提取用户ID(从选中的组织架构中获取用户)
const userIds = groupChecked.filter(id => {
@@ -431,22 +460,17 @@ export default function CrossCheckingUpload() {
return;
}
// 创建任务数据
const docTypeMap = {
[CaseType.ADMINISTRATIVE_PENALTY]: 'XZCF',
[CaseType.ADMINISTRATIVE_PERMIT]: 'XZXK'
};
// 使用文档类型名称作为 doc_type
const uploadResult = await batchUploadAndAssignCrossCheckingFiles(
filesToUpload,
CASE_TYPE_TO_TYPE_ID[caseType],
selectedDocTypeId, // 使用选中的文档类型ID
priority,
documentNumber,
remark,
isTestDocument,
userIds,
taskInfo.name,
docTypeMap[caseType] || 'XZCF',
selectedDocType.name, // 使用文档类型名称
frontendJWT
);
@@ -814,29 +838,36 @@ export default function CrossCheckingUpload() {
<div className="flex justify-center mb-6">
<div>
<div className="text-sm font-medium text-gray-700 mb-3 text-center"></div>
<div className="case-type-options">
<button
type="button"
className={`case-type-option ${caseType === CaseType.ADMINISTRATIVE_PENALTY ? 'active' : 'inactive'}`}
onClick={() => handleCaseTypeChange(CaseType.ADMINISTRATIVE_PENALTY)}
disabled={isUploading}
>
</button>
<button
type="button"
className={`case-type-option ${caseType === CaseType.ADMINISTRATIVE_PERMIT ? 'active' : 'inactive'}`}
onClick={() => handleCaseTypeChange(CaseType.ADMINISTRATIVE_PERMIT)}
disabled={isUploading}
>
</button>
</div>
{documentTypesError ? (
<div className="text-red-500 text-sm text-center p-4 border border-red-200 rounded-md bg-red-50">
<i className="ri-error-warning-line mr-2"></i>
: {documentTypesError}
</div>
) : documentTypes && documentTypes.length > 0 ? (
<div className="case-type-options">
{documentTypes.map((docType: DocumentType) => (
<button
key={docType.id}
type="button"
className={`case-type-option ${selectedDocTypeId === docType.id ? 'active' : 'inactive'}`}
onClick={() => handleDocTypeChange(docType.id)}
disabled={isUploading}
>
{docType.name}
</button>
))}
</div>
) : (
<div className="text-gray-500 text-sm text-center p-4 border border-gray-200 rounded-md bg-gray-50">
<i className="ri-information-line mr-2"></i>
</div>
)}
</div>
</div>
{/* 文件上传区域 */}
<input type="hidden" name="caseType" value={caseType} />
<input type="hidden" name="selectedDocTypeId" value={selectedDocTypeId || ''} />
<input type="hidden" name="uploadType" value={uploadType} />
{/* 上传框区域 */}
@@ -851,14 +882,14 @@ export default function CrossCheckingUpload() {
ref={singleUploadRef}
onFilesSelected={handleSingleFilesSelected}
className="custom-upload-area"
accept=".pdf"
accept=".pdf,.docx"
multiple={true}
icon="ri-file-upload-line"
buttonText="选择文件"
mainText="点击或拖拽文件到此区域上传"
tipText={
<div className="upload-tip-error">
PDF文件
PDF或DOCX文件
</div>
}
disabled={uploadType === 'multiple' || isUploading}
@@ -911,23 +942,29 @@ export default function CrossCheckingUpload() {
{/* 单案件文件列表 */}
{uploadType === 'single' && singleFiles.length > 0 && (
<div className="max-h-32 overflow-y-auto space-y-1">
{singleFiles.map((file) => (
<div key={file.id} className="flex items-center justify-between bg-white p-2 rounded border">
<div className="flex items-center space-x-2 flex-1 min-w-0">
<i className="ri-file-pdf-line text-red-500"></i>
<span className="text-sm truncate">{file.name}</span>
<span className="text-xs text-gray-500">{formatFileSize(file.size)}</span>
{singleFiles.map((file) => {
const isDocx = file.name.toLowerCase().endsWith('.docx');
const isPdf = file.name.toLowerCase().endsWith('.pdf');
return (
<div key={file.id} className="flex items-center justify-between bg-white p-2 rounded border">
<div className="flex items-center space-x-2 flex-1 min-w-0">
{isPdf && <i className="ri-file-pdf-line text-red-500"></i>}
{isDocx && <i className="ri-file-word-2-line text-blue-500"></i>}
{!isPdf && !isDocx && <i className="ri-file-line text-gray-500"></i>}
<span className="text-sm truncate">{file.name}</span>
<span className="text-xs text-gray-500">{formatFileSize(file.size)}</span>
</div>
<button
type="button"
onClick={() => handleRemoveFile(file.id, 'single')}
className="text-red-500 hover:text-red-700 p-1"
disabled={isUploading}
>
<i className="ri-close-line"></i>
</button>
</div>
<button
type="button"
onClick={() => handleRemoveFile(file.id, 'single')}
className="text-red-500 hover:text-red-700 p-1"
disabled={isUploading}
>
<i className="ri-close-line"></i>
</button>
</div>
))}
);
})}
</div>
)}
+25 -4
View File
@@ -1549,8 +1549,19 @@ export default function DocumentsIndex() {
{/* 附件追加模态框 */}
{showAttachmentUpload && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]"
onClick={() => {
setShowAttachmentUpload(false);
setSelectedDocumentId(null);
setAttachmentFiles([]);
setAttachmentRemark("");
}}
>
<div
className="bg-white rounded-lg p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold"></h3>
<button
@@ -1688,8 +1699,18 @@ export default function DocumentsIndex() {
{/* 合同模板上传模态框 */}
{showTemplateUpload && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-lg mx-4">
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]"
onClick={() => {
setShowTemplateUpload(false);
setSelectedDocumentId(null);
setTemplateFile(null);
}}
>
<div
className="bg-white rounded-lg p-6 w-full max-w-lg mx-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold"></h3>
<button
+1 -1
View File
@@ -276,7 +276,7 @@ export default function EntryModulesList() {
rel="noopener noreferrer"
className="ml-2 text-blue-600 hover:underline text-sm"
>
<div className="h-8 w-8 bg-gray-100 rounded flex items-center justify-center overflow-hidden">
<div className="h-10 w-10 bg-gray-100 rounded flex items-center justify-center overflow-hidden">
<img
src={logoUrl}
alt={record.name}
+47 -15
View File
@@ -43,6 +43,9 @@ import {
Comparison
} from "~/components/reviews";
// 导入文档对比组件
import { ComparePreview } from "~/components/reviews/previewComponents/ComparePreview";
// 从ReviewPointsList组件中导入ReviewPoint类型
import { type ReviewPoint } from '~/components/reviews';
import { messageService } from "~/components/ui/MessageModal";
@@ -306,6 +309,7 @@ export default function ReviewDetails() {
const [targetPage, setTargetPage] = useState<number | undefined>(undefined);
const [templateTargetPage, setTemplateTargetPage] = useState<number | undefined>(undefined);
const [charPositions, setCharPositions] = useState<Array<{ box: number[][], char: string, score: number }> | undefined>(undefined);
const [highlightValue, setHighlightValue] = useState<string | undefined>(undefined);
const [pendingUpdate, setPendingUpdate] = useState<{
reviewPointResultId: string;
newStatus: string;
@@ -352,10 +356,22 @@ export default function ReviewDetails() {
},[loaderData, navigate]);
// 当文档 ID 变化时,清空高亮相关的状态
useEffect(() => {
if (document?.id) {
console.log('[Reviews] 文档ID变化,清空高亮状态');
setActiveReviewPointResultId(null);
setTargetPage(undefined);
setTemplateTargetPage(undefined);
setCharPositions(undefined);
setHighlightValue(undefined);
}
}, [document?.id]);
// 模拟获取评查数据
useEffect(() => {
if (!document) return;
// 构建文件信息对象
const fileInfo = {
fileName: document.name || "未知文件名",
@@ -395,22 +411,25 @@ export default function ReviewDetails() {
setActiveTab(tabKey);
};
const handleReviewPointSelect = (reviewPointId: string, page?: number, charPos?: Array<{ box: number[][], char: string, score: number }>) => {
const handleReviewPointSelect = (reviewPointId: string, page?: number, charPos?: Array<{ box: number[][], char: string, score: number }>, value?: string) => {
// 如果点击的是相同的评查点,但有page参数,先重置targetPage以确保useEffect能够触发
if (reviewPointId === activeReviewPointResultId && page) {
setTargetPage(undefined);
setCharPositions(undefined);
// 使用setTimeout确保状态更新后再设置新的targetPage和charPositions
setHighlightValue(undefined);
// 使用setTimeout确保状态更新后再设置新的targetPage、charPositions和highlightValue
setTimeout(() => {
setActiveReviewPointResultId(reviewPointId);
setTargetPage(page);
setCharPositions(charPos);
setHighlightValue(value);
}, 0);
} else {
// 正常设置activeReviewPointId、targetPagecharPositions
// 正常设置activeReviewPointId、targetPagecharPositions和highlightValue
setActiveReviewPointResultId(reviewPointId);
setTargetPage(page);
setCharPositions(charPos);
setHighlightValue(value);
}
};
@@ -733,7 +752,7 @@ export default function ReviewDetails() {
previousRoute: loaderData.previousRoute,
path: document?.path,
auditStatus: document?.auditStatus,
type: document?.type,
type: document?.type || document?.type_id,
comparisonId: comparison_document?.id ? Number(comparison_document.id) : undefined
}}
onConfirmResults={handleConfirmResults}
@@ -742,6 +761,7 @@ export default function ReviewDetails() {
{/* 评查结果选项卡内容 */}
{activeTab === 'preview' && (
<div className="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:space-x-4">
{/* {JSON.stringify(document)} */}
{/* 左侧:文件预览 */}
<div className="w-full lg:w-[65%]">
{(() => {
@@ -759,6 +779,8 @@ export default function ReviewDetails() {
activeReviewPointResultId={activeReviewPointResultId}
targetPage={targetPage}
charPositions={charPositions}
highlightValue={highlightValue}
userInfo={loaderData.userInfo}
/>
);
})()}
@@ -779,8 +801,23 @@ export default function ReviewDetails() {
{/* 结构比对选项卡内容 */}
{activeTab === 'filecompare' && (
<div className="w-full" style={{
height: 'calc(100vh - 120px)',
minHeight: '600px',
display: 'flex',
flexDirection: 'column'
}}>
{/* {JSON.stringify(comparison_document?.template_contract_path)} -----{JSON.stringify(document?.path)} */}
<ComparePreview
doc1Path={document?.path || ''}
doc2Path={comparison_document?.template_contract_path || ''}
/>
</div>
)}
{/* 原来的结构比对选项卡内容(已注释) */}
{/* {activeTab === 'filecompare' && (
<div className="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:space-x-4">
{/* 左侧:原文件预览 */}
<div className={`w-full ${comparison_document.template_contract_path ? 'lg:w-[38%]' : 'lg:w-[56%]'}`}>
<FilePreview
fileContent={document}
@@ -790,10 +827,9 @@ export default function ReviewDetails() {
charPositions={charPositions}
/>
</div>
{/* 中间:附件文件预览 */}
<div className={`w-full ${comparison_document.template_contract_path ? 'lg:w-[38%]' : 'lg:w-[20%]'}`}>
<FilePreview
<FilePreview
fileContent={comparison_document}
reviewPoints={[]}
activeReviewPointResultId={activeReviewPointResultId}
@@ -801,15 +837,12 @@ export default function ReviewDetails() {
isStructuredView={true}
/>
</div>
{/* 右侧:结构比较结果 */}
<div className="w-full lg:w-[24%]">
<Comparison
comparison_document={comparison_document}
onPageJump={(sourcePage, templatePage) => {
// 同时处理主文件和模板文件的页码跳转
if (sourcePage > 0) {
// 如果目标页码与当前页码相同,先重置再设置以强制触发更新
if (sourcePage === targetPage) {
setTargetPage(undefined);
setTimeout(() => setTargetPage(sourcePage), 0);
@@ -819,7 +852,6 @@ export default function ReviewDetails() {
console.log(`跳转到主文件第${sourcePage}页`);
}
if (templatePage > 0) {
// 如果目标页码与当前页码相同,先重置再设置以强制触发更新
if (templatePage === templateTargetPage) {
setTemplateTargetPage(undefined);
setTimeout(() => setTemplateTargetPage(templatePage), 0);
@@ -832,7 +864,7 @@ export default function ReviewDetails() {
/>
</div>
</div>
)}
)} */}
{/* AI智能分析选项卡内容 */}
{activeTab === 'analysis' && (