feat: 1. 接入CollaboraViewer选中的高亮效果,清除高亮功能,页面销毁自动清除高亮。
2. 合同模板对比接入monaco editor的效果。 3. 添加交叉评查的案卷类型的数据查询。 fix: 1. 修复文档列表的打开模态框蒙板层显示效果。
This commit is contained in:
@@ -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 : '获取文档类型失败'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -13,6 +13,12 @@ export {
|
||||
unoScrollToTop
|
||||
} from './navigation';
|
||||
|
||||
// 缩放功能
|
||||
export {
|
||||
unoZoomIn,
|
||||
unoZoomOut,
|
||||
unoSetZoom
|
||||
} from './zoom';
|
||||
|
||||
// 页数信息
|
||||
export {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}对应的索引内容`);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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、targetPage和charPositions
|
||||
// 正常设置activeReviewPointId、targetPage、charPositions和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' && (
|
||||
|
||||
Reference in New Issue
Block a user