Merge branch 'shiy-login' into Wren
This commit is contained in:
+11
-11
@@ -90,12 +90,12 @@ axiosInstance.interceptors.request.use(
|
|||||||
const token = localStorage.getItem('access_token');
|
const token = localStorage.getItem('access_token');
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
console.log('🔑 [Request Interceptor] 添加Authorization头:', {
|
// console.log('🔑 [Request Interceptor] 添加Authorization头:', {
|
||||||
url: config.url,
|
// url: config.url,
|
||||||
method: config.method,
|
// method: config.method,
|
||||||
hasToken: !!token,
|
// hasToken: !!token,
|
||||||
tokenPreview: token.substring(0, 20) + '...'
|
// tokenPreview: token.substring(0, 20) + '...'
|
||||||
});
|
// });
|
||||||
} else {
|
} else {
|
||||||
console.warn('⚠️ [Request Interceptor] 没有找到access_token:', {
|
console.warn('⚠️ [Request Interceptor] 没有找到access_token:', {
|
||||||
url: config.url,
|
url: config.url,
|
||||||
@@ -127,11 +127,11 @@ export class AuthenticationError extends Error {
|
|||||||
*/
|
*/
|
||||||
axiosInstance.interceptors.response.use(
|
axiosInstance.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
console.log('✅ [Response Interceptor] 请求成功:', {
|
// console.log('✅ [Response Interceptor] 请求成功:', {
|
||||||
url: response.config.url,
|
// url: response.config.url,
|
||||||
status: response.status,
|
// status: response.status,
|
||||||
statusText: response.statusText
|
// statusText: response.statusText
|
||||||
});
|
// });
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
|
|||||||
@@ -461,22 +461,22 @@ export async function getReviewPoints(fileId: string, request: Request) {
|
|||||||
evaluatedPointResultsLog: evaluatedPointResultsLog || {}
|
evaluatedPointResultsLog: evaluatedPointResultsLog || {}
|
||||||
// evaluatedPointResultsLog: {
|
// evaluatedPointResultsLog: {
|
||||||
// rules:[
|
// rules:[
|
||||||
// {
|
// {
|
||||||
// "id": "0",
|
// "id": "0",
|
||||||
// "type": "consistency",
|
// "type": "consistency",
|
||||||
// "res": true,
|
// "res": true,
|
||||||
// "config": {
|
// "config": {
|
||||||
// "logic": "all",
|
// "logic": "all",
|
||||||
// "pairs": [
|
// "pairs": [
|
||||||
// {
|
// {
|
||||||
// "sourceField": {"证据先行登记保存批准书-负责人意见并签名-时间": {page: 1,value: ''}},
|
// "sourceField": {"证据先行登记保存批准书-负责人意见并签名-时间": {page: 1,value: ''}},
|
||||||
// "targetField": {"证据先行登记保存批准书-负责人意见并签名-签名": {page: 2,value: '有无判断类型'}},
|
// "targetField": {"证据先行登记保存批准书-负责人意见并签名-签名": {page: 2,value: '有无判断类型'}},
|
||||||
// "compareMethod": "exact",
|
// "compareMethod": "exact",
|
||||||
// "res": true
|
// "res": true
|
||||||
// }
|
// }
|
||||||
// ]
|
// ]
|
||||||
// }
|
// }
|
||||||
// },
|
// },
|
||||||
// {
|
// {
|
||||||
// "id": "1",
|
// "id": "1",
|
||||||
// "type": "consistency",
|
// "type": "consistency",
|
||||||
|
|||||||
@@ -10,30 +10,57 @@
|
|||||||
* @encoding UTF-8
|
* @encoding UTF-8
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useRef } from 'react';
|
import { useRef, forwardRef, useImperativeHandle, useState } from 'react';
|
||||||
import type { CollaboraViewerProps } from './types';
|
import type { CollaboraViewerProps, CollaboraViewerHandle } from './types';
|
||||||
import { useCollaboraConfig, useDocumentReady, useCollaboraUnoCommands } from './hooks';
|
import { useCollaboraConfig, useDocumentReady, useCollaboraUnoCommands } from './hooks';
|
||||||
|
import { sendUnoCommand } from './Uno';
|
||||||
|
import { switchHighlight } from './lib/Highlightselecttext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collabora 文档查看器组件
|
* Collabora 文档查看器组件
|
||||||
* @param props - 组件属性
|
* @param props - 组件属性
|
||||||
|
* @param ref - 父组件传入的 ref,用于暴露命令接口
|
||||||
*/
|
*/
|
||||||
export function CollaboraViewer({
|
export const CollaboraViewer = forwardRef<CollaboraViewerHandle, CollaboraViewerProps>(
|
||||||
fileId,
|
function CollaboraViewer(
|
||||||
mode = 'view',
|
{
|
||||||
userId = 'guest',
|
fileId,
|
||||||
userName = '访客',
|
mode = 'view',
|
||||||
}: CollaboraViewerProps) {
|
userId = 'guest',
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
userName = '访客',
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
|
||||||
// 1. 加载 Collabora 配置
|
// 调试面板状态
|
||||||
const { config, loading, error } = useCollaboraConfig(fileId, mode, userId, userName);
|
const [unoCmd, setUnoCmd] = useState('.uno:GoToStartOfDoc');
|
||||||
|
const [unoArgs, setUnoArgs] = useState('{}');
|
||||||
|
const [unoResult, setUnoResult] = useState<string | null>(null);
|
||||||
|
|
||||||
// 2. 监听文档加载状态
|
// 高亮测试面板状态
|
||||||
const { isDocumentLoaded } = useDocumentReady(iframeRef);
|
const [highlightText, setHighlightText] = useState('');
|
||||||
|
const [highlightPage, setHighlightPage] = useState('');
|
||||||
|
const [previousHighlightText, setPreviousHighlightText] = useState<string | null>(null);
|
||||||
|
const [highlightResult, setHighlightResult] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 1. 加载 Collabora 配置
|
||||||
|
const { config, loading, error } = useCollaboraConfig(fileId, mode, userId, userName);
|
||||||
|
|
||||||
|
// 2. 监听文档加载状态
|
||||||
|
const { isDocumentLoaded } = useDocumentReady(iframeRef);
|
||||||
|
|
||||||
|
// 3. UNO 命令封装
|
||||||
|
const unoCommands = useCollaboraUnoCommands(iframeRef);
|
||||||
|
|
||||||
|
// 4. 暴露接口给父组件
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
unoCommands,
|
||||||
|
isReady: isDocumentLoaded,
|
||||||
|
mode,
|
||||||
|
getIframeWindow: () => iframeRef.current?.contentWindow || null,
|
||||||
|
}), [unoCommands, isDocumentLoaded, mode]);
|
||||||
|
|
||||||
// 3. UNO 命令封装
|
|
||||||
const unoCommands = useCollaboraUnoCommands(iframeRef);
|
|
||||||
|
|
||||||
// 加载中状态
|
// 加载中状态
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -60,8 +87,154 @@ export function CollaboraViewer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 发送 UNO 命令的处理函数(测试用)
|
||||||
|
// const sendUno = () => {
|
||||||
|
// if (!iframeRef.current?.contentWindow) {
|
||||||
|
// setUnoResult('iframe 不可用');
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// let args: Record<string, unknown> = {};
|
||||||
|
// const raw = (unoArgs || '').trim();
|
||||||
|
// if (raw !== '') {
|
||||||
|
// try {
|
||||||
|
// args = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
// } catch (err) {
|
||||||
|
// try {
|
||||||
|
// // fallback: replace single quotes with double quotes and parse
|
||||||
|
// args = JSON.parse(raw.replace(/'(.*?)'/g, '"$1"')) as Record<string, unknown>;
|
||||||
|
// } catch (err2) {
|
||||||
|
// console.error('解析 UNO Args 失败:', err2);
|
||||||
|
// setUnoResult('Args 解析失败,请使用有效 JSON');
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// // 使用正确的 sendUnoCommand 方法
|
||||||
|
// sendUnoCommand(iframeRef.current.contentWindow, unoCmd, args);
|
||||||
|
// setUnoResult(`已发送: ${unoCmd}`);
|
||||||
|
// console.log('[UNO Debug] 发送命令:', unoCmd, args);
|
||||||
|
// } catch (e) {
|
||||||
|
// console.error('发送 UNO 失败:', e);
|
||||||
|
// setUnoResult('发送失败,请查看控制台');
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// 高亮测试处理函数
|
||||||
|
const handleSwitchHighlight = async () => {
|
||||||
|
if (!iframeRef.current?.contentWindow) {
|
||||||
|
setHighlightResult('iframe 未就绪');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!highlightText || highlightText.trim() === '') {
|
||||||
|
setHighlightResult('请输入要高亮的文本');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 解析页码 (可选)
|
||||||
|
const page = highlightPage && highlightPage.trim() !== ''
|
||||||
|
? parseInt(highlightPage.trim(), 10)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// 验证页码
|
||||||
|
if (page !== undefined && (isNaN(page) || page < 1)) {
|
||||||
|
setHighlightResult('页码必须是大于0的整数');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await switchHighlight(
|
||||||
|
iframeRef.current.contentWindow,
|
||||||
|
previousHighlightText,
|
||||||
|
highlightText.trim(),
|
||||||
|
{ page }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新上一次高亮的文本
|
||||||
|
setPreviousHighlightText(highlightText.trim());
|
||||||
|
const pageInfo = page ? ` (第${page}页)` : '';
|
||||||
|
setHighlightResult(`✓ 已切换高亮: ${highlightText.trim()}${pageInfo}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('切换高亮失败:', e);
|
||||||
|
setHighlightResult(`切换失败: ${e instanceof Error ? e.message : '未知错误'}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="collabora-viewer relative w-full h-full min-h-[600px]">
|
<div className="collabora-viewer relative w-full h-full min-h-[600px]">
|
||||||
|
{/* UNO 命令测试面板 */}
|
||||||
|
{/* <div className="absolute top-2 left-2 z-50 bg-white bg-opacity-90 px-2 py-1 rounded shadow flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="px-2 py-1 border rounded text-sm w-48"
|
||||||
|
value={unoCmd}
|
||||||
|
onChange={(e) => setUnoCmd(e.target.value)}
|
||||||
|
placeholder="UNO 命令"
|
||||||
|
aria-label="UNO 命令"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="px-2 py-1 border rounded text-sm w-64"
|
||||||
|
value={unoArgs}
|
||||||
|
onChange={(e) => setUnoArgs(e.target.value)}
|
||||||
|
placeholder="UNO Args (JSON)"
|
||||||
|
aria-label="UNO Args (JSON)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 bg-indigo-600 text-white rounded hover:bg-indigo-700 text-sm"
|
||||||
|
onClick={sendUno}
|
||||||
|
>
|
||||||
|
发送 UNO
|
||||||
|
</button>
|
||||||
|
{unoResult && <span className="text-xs text-gray-500 ml-2">{unoResult}</span>}
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
{/* 高亮测试面板 */}
|
||||||
|
<div className="absolute top-2 left-2 z-50 bg-white bg-opacity-90 px-3 py-2 rounded shadow flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="px-3 py-1.5 border rounded text-sm w-64"
|
||||||
|
value={highlightText}
|
||||||
|
onChange={(e) => setHighlightText(e.target.value)}
|
||||||
|
placeholder="输入要高亮的文本 (如: 合同编号)"
|
||||||
|
aria-label="高亮文本"
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSwitchHighlight();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="px-3 py-1.5 border rounded text-sm w-20"
|
||||||
|
value={highlightPage}
|
||||||
|
onChange={(e) => setHighlightPage(e.target.value)}
|
||||||
|
placeholder="页码"
|
||||||
|
aria-label="页码"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSwitchHighlight();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="px-4 py-1.5 bg-yellow-500 text-white rounded hover:bg-yellow-600 text-sm font-medium"
|
||||||
|
onClick={handleSwitchHighlight}
|
||||||
|
disabled={!isDocumentLoaded}
|
||||||
|
>
|
||||||
|
切换高亮
|
||||||
|
</button>
|
||||||
|
{previousHighlightText && (
|
||||||
|
<span className="text-xs text-gray-600">
|
||||||
|
上次: {previousHighlightText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{highlightResult && (
|
||||||
|
<span className="text-xs text-gray-700 ml-2">{highlightResult}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 文档加载提示 */}
|
{/* 文档加载提示 */}
|
||||||
{!isDocumentLoaded && (
|
{!isDocumentLoaded && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-90 z-10">
|
<div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-90 z-10">
|
||||||
@@ -73,7 +246,8 @@ export function CollaboraViewer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Collabora iframe */}
|
{/* Collabora iframe - tabIndex is needed for keyboard navigation */}
|
||||||
|
{/* eslint-disable jsx-a11y/no-noninteractive-tabindex */}
|
||||||
<iframe
|
<iframe
|
||||||
ref={iframeRef}
|
ref={iframeRef}
|
||||||
src={config.iframeUrl}
|
src={config.iframeUrl}
|
||||||
@@ -85,10 +259,13 @@ export function CollaboraViewer({
|
|||||||
allow="clipboard-read; clipboard-write"
|
allow="clipboard-read; clipboard-write"
|
||||||
title={`Collabora Online - ${config.fileName}`}
|
title={`Collabora Online - ${config.fileName}`}
|
||||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-modals"
|
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-modals"
|
||||||
|
tabIndex={0}
|
||||||
/>
|
/>
|
||||||
|
{/* eslint-enable jsx-a11y/no-noninteractive-tabindex */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
// 导出 UNO 命令 hook 供父组件使用(如果需要)
|
// 导出类型和 hook
|
||||||
|
export type { CollaboraViewerHandle };
|
||||||
export { useCollaboraUnoCommands };
|
export { useCollaboraUnoCommands };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Collabora Online UNO 命令工具函数
|
* Collabora Online UNO 命令核心工具
|
||||||
*
|
*
|
||||||
* 职责: 封装 Collabora iframe 的 UNO 命令调用
|
* 职责: 提供基础的 UNO 命令发送和状态监听功能
|
||||||
*
|
*
|
||||||
* @encoding UTF-8
|
* @encoding UTF-8
|
||||||
*/
|
*/
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
export function sendUnoCommand(
|
export function sendUnoCommand(
|
||||||
iframeWindow: Window,
|
iframeWindow: Window,
|
||||||
command: string,
|
command: string,
|
||||||
args: Record<string, any> = {}
|
args: Record<string, unknown> = {}
|
||||||
): void {
|
): void {
|
||||||
const message = {
|
const message = {
|
||||||
MessageId: 'Send_UNO_Command',
|
MessageId: 'Send_UNO_Command',
|
||||||
@@ -30,121 +30,6 @@ export function sendUnoCommand(
|
|||||||
iframeWindow.postMessage(JSON.stringify(message), '*');
|
iframeWindow.postMessage(JSON.stringify(message), '*');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 搜索文本
|
|
||||||
* @param iframeWindow - iframe 的 contentWindow
|
|
||||||
* @param text - 要搜索的文本
|
|
||||||
*/
|
|
||||||
export function unoSearchText(iframeWindow: Window, text: string): void {
|
|
||||||
sendUnoCommand(iframeWindow, '.uno:ExecuteSearch', {
|
|
||||||
'SearchItem.SearchString': { type: 'string', value: text },
|
|
||||||
'SearchItem.Command': { type: 'long', value: 1 }, // 1 = Search Next (搜索下一个)
|
|
||||||
'SearchItem.Backward': { type: 'boolean', value: false },
|
|
||||||
'SearchItem.Pattern': { type: 'boolean', value: false },
|
|
||||||
'SearchItem.Content': { type: 'boolean', value: false },
|
|
||||||
'SearchItem.AsianOptions': { type: 'boolean', value: false },
|
|
||||||
'SearchItem.AlgorithmType': { type: 'short', value: 0 }, // 普通搜索
|
|
||||||
'SearchItem.SearchFlags': { type: 'long', value: 0 },
|
|
||||||
'SearchItem.Start': { type: 'boolean', value: true }, // 从头开始搜索
|
|
||||||
'SearchItem.Quiet': { type: 'boolean', value: true }, // 静默模式
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 替换文本
|
|
||||||
* @param iframeWindow - iframe 的 contentWindow
|
|
||||||
* @param searchText - 要搜索的文本
|
|
||||||
* @param replaceText - 替换后的文本
|
|
||||||
*/
|
|
||||||
export function unoReplaceText(
|
|
||||||
iframeWindow: Window,
|
|
||||||
searchText: string,
|
|
||||||
replaceText: string
|
|
||||||
): void {
|
|
||||||
sendUnoCommand(iframeWindow, '.uno:ExecuteSearch', {
|
|
||||||
'SearchItem.SearchString': { type: 'string', value: searchText },
|
|
||||||
'SearchItem.ReplaceString': { type: 'string', value: replaceText },
|
|
||||||
'SearchItem.Command': { type: 'long', value: 3 }, // 3 = ReplaceAll
|
|
||||||
'SearchItem.AlgorithmType': { type: 'short', value: 0 },
|
|
||||||
'SearchItem.SearchFlags': { type: 'long', value: 0 },
|
|
||||||
'SearchItem.Backward': { type: 'boolean', value: false },
|
|
||||||
'Quiet': { type: 'boolean', value: true },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 高亮文本
|
|
||||||
* @param iframeWindow - iframe 的 contentWindow
|
|
||||||
* @param text - 要高亮的文本
|
|
||||||
* @param color - 高亮颜色,默认 16776960 = 黄色
|
|
||||||
*/
|
|
||||||
export function unoHighlightText(
|
|
||||||
iframeWindow: Window,
|
|
||||||
text: string,
|
|
||||||
color: number = 16776960
|
|
||||||
): void {
|
|
||||||
// 1. 查找所有
|
|
||||||
sendUnoCommand(iframeWindow, '.uno:ExecuteSearch', {
|
|
||||||
'SearchItem.SearchString': { type: 'string', value: text },
|
|
||||||
'SearchItem.Command': { type: 'long', value: 1 }, // 1 = FindAll
|
|
||||||
'SearchItem.SearchFlags': { type: 'long', value: 0 },
|
|
||||||
'SearchItem.AlgorithmType': { type: 'short', value: 0 },
|
|
||||||
'SearchItem.Backward': { type: 'boolean', value: false },
|
|
||||||
'Quiet': { type: 'boolean', value: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. 设置背景色
|
|
||||||
sendUnoCommand(iframeWindow, '.uno:BackColor', {
|
|
||||||
BackColor: { type: 'long', value: color },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 移除高亮
|
|
||||||
* @param iframeWindow - iframe 的 contentWindow
|
|
||||||
* @param text - 要移除高亮的文本
|
|
||||||
*/
|
|
||||||
export function unoRemoveHighlight(iframeWindow: Window, text: string): void {
|
|
||||||
// 1. 查找所有
|
|
||||||
sendUnoCommand(iframeWindow, '.uno:ExecuteSearch', {
|
|
||||||
'SearchItem.SearchString': { type: 'string', value: text },
|
|
||||||
'SearchItem.Command': { type: 'long', value: 1 }, // 1 = FindAll
|
|
||||||
'SearchItem.SearchFlags': { type: 'long', value: 0 },
|
|
||||||
'SearchItem.AlgorithmType': { type: 'short', value: 0 },
|
|
||||||
'SearchItem.Backward': { type: 'boolean', value: false },
|
|
||||||
'Quiet': { type: 'boolean', value: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. 移除背景色 -1 = 无色
|
|
||||||
sendUnoCommand(iframeWindow, '.uno:BackColor', {
|
|
||||||
BackColor: { type: 'long', value: -1 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 取消 - Escape
|
|
||||||
* @param iframeWindow - iframe 的 contentWindow
|
|
||||||
*/
|
|
||||||
export function unoEscape(iframeWindow: Window): void {
|
|
||||||
sendUnoCommand(iframeWindow, '.uno:Escape', {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 滚动到文档开头
|
|
||||||
* @param iframeWindow - iframe 的 contentWindow
|
|
||||||
*/
|
|
||||||
export function unoScrollToTop(iframeWindow: Window): void {
|
|
||||||
sendUnoCommand(iframeWindow, '.uno:GoToStartOfDoc', {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存文档
|
|
||||||
* @param iframeWindow - iframe 的 contentWindow
|
|
||||||
*/
|
|
||||||
export function unoSave(iframeWindow: Window): void {
|
|
||||||
sendUnoCommand(iframeWindow, '.uno:Save');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取文档状态 (用于检测命令队列完成)
|
* 获取文档状态 (用于检测命令队列完成)
|
||||||
* @param iframeWindow - iframe 的 contentWindow
|
* @param iframeWindow - iframe 的 contentWindow
|
||||||
|
|||||||
@@ -9,20 +9,14 @@
|
|||||||
* @encoding UTF-8
|
* @encoding UTF-8
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { RefObject, useCallback, useEffect, useState, useMemo } from 'react';
|
|
||||||
import { useFetcher } from '@remix-run/react';
|
import { useFetcher } from '@remix-run/react';
|
||||||
import { toastService } from '../ui/Toast';
|
import { RefObject, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import type { CollaboraConfig } from './types';
|
|
||||||
import {
|
|
||||||
unoSearchText,
|
|
||||||
unoReplaceText,
|
|
||||||
unoHighlightText,
|
|
||||||
unoRemoveHighlight,
|
|
||||||
unoEscape,
|
|
||||||
unoScrollToTop,
|
|
||||||
unoSave,
|
|
||||||
} from './Uno';
|
|
||||||
import { COLLABORA_URL } from '~/config/api-config';
|
import { COLLABORA_URL } from '~/config/api-config';
|
||||||
|
import { toastService } from '../ui/Toast';
|
||||||
|
import {
|
||||||
|
unoScrollToTop,
|
||||||
|
} from './lib';
|
||||||
|
import type { CollaboraConfig } from './types';
|
||||||
|
|
||||||
// ==================== 1. 配置加载 ====================
|
// ==================== 1. 配置加载 ====================
|
||||||
|
|
||||||
@@ -61,7 +55,8 @@ export function useCollaboraConfig(
|
|||||||
// 检查错误
|
// 检查错误
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fetcher.data && 'error' in fetcher.data) {
|
if (fetcher.data && 'error' in fetcher.data) {
|
||||||
const errorMessage = (fetcher.data as any).error || '加载配置失败';
|
const errorData = fetcher.data as { error: string };
|
||||||
|
const errorMessage = errorData.error || '加载配置失败';
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
toastService.error(`加载文档配置失败: ${errorMessage}`);
|
toastService.error(`加载文档配置失败: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
@@ -122,115 +117,6 @@ export function useDocumentReady(iframeRef: RefObject<HTMLIFrameElement>) {
|
|||||||
* @returns UNO 命令方法集合
|
* @returns UNO 命令方法集合
|
||||||
*/
|
*/
|
||||||
export function useCollaboraUnoCommands(iframeRef: RefObject<HTMLIFrameElement>) {
|
export function useCollaboraUnoCommands(iframeRef: RefObject<HTMLIFrameElement>) {
|
||||||
/**
|
|
||||||
* 搜索文本(用于定位)
|
|
||||||
*/
|
|
||||||
const searchText = useCallback(
|
|
||||||
async (text: string) => {
|
|
||||||
if (!iframeRef.current?.contentWindow) {
|
|
||||||
console.warn('[UNO] iframe 不可用');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[UNO] 搜索文本: "${text}"`);
|
|
||||||
unoSearchText(iframeRef.current.contentWindow, text);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
},
|
|
||||||
[iframeRef]
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 定位文本(搜索 + 立即取消选中)
|
|
||||||
* 用于"只看不改"的场景,避免蓝色选中背景
|
|
||||||
*/
|
|
||||||
const locateText = useCallback(
|
|
||||||
async (text: string) => {
|
|
||||||
if (!iframeRef.current?.contentWindow) {
|
|
||||||
console.warn('[UNO] iframe 不可用');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[UNO] 定位文本(无选中): "${text}"`);
|
|
||||||
|
|
||||||
// 1. 执行搜索(滚动到目标并选中)
|
|
||||||
await searchText(text);
|
|
||||||
|
|
||||||
// 2. 等待渲染完成
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
|
|
||||||
// 3. 取消选中(去除蓝色背景,保留视图位置)
|
|
||||||
unoEscape(iframeRef.current.contentWindow);
|
|
||||||
|
|
||||||
console.log(`[UNO] 定位完成,已取消选中`);
|
|
||||||
},
|
|
||||||
[searchText, iframeRef]
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 替换文本(ReplaceAll)
|
|
||||||
*/
|
|
||||||
const replaceText = useCallback(
|
|
||||||
async (searchText: string, replaceText: string) => {
|
|
||||||
if (!iframeRef.current?.contentWindow) {
|
|
||||||
console.warn('[UNO] iframe 不可用');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[UNO] 替换文本: "${searchText}" -> "${replaceText}"`);
|
|
||||||
unoReplaceText(iframeRef.current.contentWindow, searchText, replaceText);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
||||||
},
|
|
||||||
[iframeRef]
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 高亮文本
|
|
||||||
*/
|
|
||||||
const highlightText = useCallback(
|
|
||||||
async (text: string, color?: number) => {
|
|
||||||
if (!iframeRef.current?.contentWindow) {
|
|
||||||
console.warn('[UNO] iframe 不可用');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[UNO] 高亮文本: "${text}"`);
|
|
||||||
unoHighlightText(iframeRef.current.contentWindow, text, color);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
||||||
},
|
|
||||||
[iframeRef]
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 移除高亮
|
|
||||||
*/
|
|
||||||
const removeHighlight = useCallback(
|
|
||||||
async (text: string) => {
|
|
||||||
if (!iframeRef.current?.contentWindow) {
|
|
||||||
console.warn('[UNO] iframe 不可用');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[UNO] 移除高亮: "${text}"`);
|
|
||||||
unoRemoveHighlight(iframeRef.current.contentWindow, text);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
||||||
},
|
|
||||||
[iframeRef]
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 取消选中(Escape)
|
|
||||||
*/
|
|
||||||
const escapeSelection = useCallback(async () => {
|
|
||||||
if (!iframeRef.current?.contentWindow) {
|
|
||||||
console.warn('[UNO] iframe 不可用');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[UNO] 取消选中');
|
|
||||||
unoEscape(iframeRef.current.contentWindow);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
}, [iframeRef]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 滚动到文档顶部
|
* 滚动到文档顶部
|
||||||
*/
|
*/
|
||||||
@@ -241,44 +127,18 @@ export function useCollaboraUnoCommands(iframeRef: RefObject<HTMLIFrameElement>)
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('[UNO] 滚动到顶部');
|
console.log('[UNO] 滚动到顶部');
|
||||||
unoScrollToTop(iframeRef.current.contentWindow);
|
await unoScrollToTop(iframeRef.current.contentWindow);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
}, [iframeRef]);
|
}, [iframeRef]);
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存文档
|
|
||||||
*/
|
|
||||||
const saveDocument = useCallback(async () => {
|
|
||||||
if (!iframeRef.current?.contentWindow) {
|
|
||||||
console.warn('[UNO] iframe 不可用');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[UNO] 保存文档');
|
|
||||||
unoSave(iframeRef.current.contentWindow);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
}, [iframeRef]);
|
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
searchText,
|
scrollToTop
|
||||||
locateText,
|
|
||||||
replaceText,
|
|
||||||
highlightText,
|
|
||||||
removeHighlight,
|
|
||||||
escapeSelection,
|
|
||||||
scrollToTop,
|
|
||||||
saveDocument,
|
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
searchText,
|
scrollToTop
|
||||||
locateText,
|
|
||||||
replaceText,
|
|
||||||
highlightText,
|
|
||||||
removeHighlight,
|
|
||||||
escapeSelection,
|
|
||||||
scrollToTop,
|
|
||||||
saveDocument,
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* Collabora Online 高亮选中文本工具
|
||||||
|
*
|
||||||
|
* 职责: 实现文本的搜索、高亮、清除高亮功能,支持跳转到指定页面
|
||||||
|
*
|
||||||
|
* 核心逻辑:
|
||||||
|
* 1. 搜索并高亮指定文本
|
||||||
|
* 2. 清除指定文本的高亮
|
||||||
|
* 3. 切换高亮 (清除旧文本高亮 + 高亮新文本)
|
||||||
|
* 4. 跳转到指定页面后高亮文本
|
||||||
|
*
|
||||||
|
* @encoding UTF-8
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sendUnoCommand } from "../Uno";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面跳转响应接口
|
||||||
|
*/
|
||||||
|
interface GotoPageResponse {
|
||||||
|
pageNumber: number;
|
||||||
|
pageIndex: number;
|
||||||
|
totalPages: number;
|
||||||
|
currentOffset: number;
|
||||||
|
targetOffset: number;
|
||||||
|
scrollDelta: number;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collabora PostMessage 数据类型
|
||||||
|
*/
|
||||||
|
interface CollaboraMessageData {
|
||||||
|
MessageId?: string;
|
||||||
|
msgId?: string;
|
||||||
|
Values?: {
|
||||||
|
Command?: string;
|
||||||
|
Status?: string;
|
||||||
|
pageNumber?: number;
|
||||||
|
pageIndex?: number;
|
||||||
|
totalPages?: number;
|
||||||
|
currentOffset?: number;
|
||||||
|
targetOffset?: number;
|
||||||
|
scrollDelta?: number;
|
||||||
|
timestamp?: number;
|
||||||
|
Error?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
args?: {
|
||||||
|
Command?: string;
|
||||||
|
Status?: string;
|
||||||
|
pageNumber?: number;
|
||||||
|
pageIndex?: number;
|
||||||
|
totalPages?: number;
|
||||||
|
currentOffset?: number;
|
||||||
|
targetOffset?: number;
|
||||||
|
scrollDelta?: number;
|
||||||
|
timestamp?: number;
|
||||||
|
Error?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 PostMessage 数据
|
||||||
|
*/
|
||||||
|
function parseMessageData(data: unknown): CollaboraMessageData {
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
return JSON.parse(data) as CollaboraMessageData;
|
||||||
|
}
|
||||||
|
return data as CollaboraMessageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跳转到指定页面 (自定义实现,不使用 UNO 命令)
|
||||||
|
*
|
||||||
|
* @param iframeWindow - iframe 的 contentWindow
|
||||||
|
* @param pageNumber - 页码 (从1开始)
|
||||||
|
* @returns Promise,解析为跳转响应信息
|
||||||
|
*/
|
||||||
|
async function gotoPage(
|
||||||
|
iframeWindow: Window,
|
||||||
|
pageNumber: number
|
||||||
|
): Promise<GotoPageResponse> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('页面跳转超时'));
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
const handleMessage = (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
if (event.source !== iframeWindow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parseMessageData(event.data);
|
||||||
|
|
||||||
|
if (
|
||||||
|
data.MessageId === 'custompostMessage_Resp' &&
|
||||||
|
data.Values?.Command === 'GOTO_PAGE'
|
||||||
|
) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
if (data.Values.Status === 'success') {
|
||||||
|
const response: GotoPageResponse = {
|
||||||
|
pageNumber: data.Values.pageNumber || pageNumber,
|
||||||
|
pageIndex: data.Values.pageIndex || pageNumber - 1,
|
||||||
|
totalPages: data.Values.totalPages || 0,
|
||||||
|
currentOffset: data.Values.currentOffset || 0,
|
||||||
|
targetOffset: data.Values.targetOffset || 0,
|
||||||
|
scrollDelta: data.Values.scrollDelta || 0,
|
||||||
|
timestamp: data.Values.timestamp || Date.now(),
|
||||||
|
};
|
||||||
|
resolve(response);
|
||||||
|
} else {
|
||||||
|
reject(new Error(data.Values.Error || '页面跳转失败'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
cleanup();
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
window.removeEventListener('message', handleMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', handleMessage);
|
||||||
|
|
||||||
|
// 发送跳转请求消息
|
||||||
|
const message = {
|
||||||
|
MessageId: 'custompostMessage',
|
||||||
|
Values: {
|
||||||
|
Command: 'GOTO_PAGE',
|
||||||
|
Args: {
|
||||||
|
pageNumber: pageNumber,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
iframeWindow.postMessage(JSON.stringify(message), '*');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 高亮文本
|
||||||
|
* @param iframeWindow - iframe 的 contentWindow
|
||||||
|
* @param text - 要高亮的文本
|
||||||
|
* @param color - 高亮颜色,默认 16776960 = 黄色
|
||||||
|
*/
|
||||||
|
function highlightText(
|
||||||
|
iframeWindow: Window,
|
||||||
|
text: string,
|
||||||
|
color: number = 16776960
|
||||||
|
): void {
|
||||||
|
// 1. 查找所有匹配的文本
|
||||||
|
sendUnoCommand(iframeWindow, '.uno:ExecuteSearch', {
|
||||||
|
'SearchItem.SearchString': { type: 'string', value: text },
|
||||||
|
'SearchItem.Command': { type: 'long', value: 1 }, // 1 = FindAll
|
||||||
|
'SearchItem.SearchFlags': { type: 'long', value: 0 },
|
||||||
|
'SearchItem.AlgorithmType': { type: 'short', value: 0 },
|
||||||
|
'SearchItem.Backward': { type: 'boolean', value: false },
|
||||||
|
'Quiet': { type: 'boolean', value: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 设置背景色
|
||||||
|
sendUnoCommand(iframeWindow, '.uno:BackColor', {
|
||||||
|
BackColor: { type: 'long', value: color },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除高亮
|
||||||
|
* @param iframeWindow - iframe 的 contentWindow
|
||||||
|
* @param text - 要移除高亮的文本
|
||||||
|
*/
|
||||||
|
function removeHighlight(iframeWindow: Window, text: string): void {
|
||||||
|
// 1. 查找所有匹配的文本
|
||||||
|
sendUnoCommand(iframeWindow, '.uno:ExecuteSearch', {
|
||||||
|
'SearchItem.SearchString': { type: 'string', value: text },
|
||||||
|
'SearchItem.Command': { type: 'long', value: 1 }, // 1 = FindAll
|
||||||
|
'SearchItem.SearchFlags': { type: 'long', value: 0 },
|
||||||
|
'SearchItem.AlgorithmType': { type: 'short', value: 0 },
|
||||||
|
'SearchItem.Backward': { type: 'boolean', value: false },
|
||||||
|
'Quiet': { type: 'boolean', value: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 移除背景色 -1 = 无色
|
||||||
|
sendUnoCommand(iframeWindow, '.uno:BackColor', {
|
||||||
|
BackColor: { type: 'long', value: -1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消选中 - Escape
|
||||||
|
* @param iframeWindow - iframe 的 contentWindow
|
||||||
|
*/
|
||||||
|
function escapeSelection(iframeWindow: Window): void {
|
||||||
|
sendUnoCommand(iframeWindow, '.uno:Escape', {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换高亮文本 (支持指定页面)
|
||||||
|
*
|
||||||
|
* @param iframeWindow - Collabora iframe 的 contentWindow
|
||||||
|
* @param oldText - 上一次高亮的文本 (null 表示首次高亮)
|
||||||
|
* @param newText - 当前要高亮的文本
|
||||||
|
* @param options - 可选配置
|
||||||
|
* @param options.color - 高亮颜色 (LibreOffice Decimal 格式), 默认 16776960 = 黄色
|
||||||
|
* @param options.page - 可选页码 (从1开始),指定后会先跳转到该页面再高亮
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 首次高亮
|
||||||
|
* await switchHighlight(iframeWindow, null, '{乙方名称}');
|
||||||
|
*
|
||||||
|
* // 切换到下一个字段
|
||||||
|
* await switchHighlight(iframeWindow, '{乙方名称}', '{乙方法定代表人}');
|
||||||
|
*
|
||||||
|
* // 跳转到第2页并高亮
|
||||||
|
* await switchHighlight(iframeWindow, null, '{合同编号}', { page: 2 });
|
||||||
|
*/
|
||||||
|
export async function switchHighlight(
|
||||||
|
iframeWindow: Window,
|
||||||
|
oldText: string | null,
|
||||||
|
newText: string,
|
||||||
|
options?: { color?: number; page?: number }
|
||||||
|
): Promise<void> {
|
||||||
|
const color = options?.color || 16776960;
|
||||||
|
const page = options?.page;
|
||||||
|
|
||||||
|
console.log('[HighlightSelectText] 切换高亮:', { oldText, newText, color, page });
|
||||||
|
|
||||||
|
// 1. 如果存在旧文本,先清除旧高亮 (在跳转之前清除,确保在旧页面清除)
|
||||||
|
if (oldText && oldText.trim() !== '') {
|
||||||
|
console.log('[HighlightSelectText] 清除旧高亮:', oldText);
|
||||||
|
removeHighlight(iframeWindow, oldText);
|
||||||
|
escapeSelection(iframeWindow);
|
||||||
|
// 等待清除命令执行完成
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 如果指定了页码,先跳转到该页
|
||||||
|
if (page !== undefined && page > 0) {
|
||||||
|
try {
|
||||||
|
console.log('[HighlightSelectText] 跳转到第', page, '页');
|
||||||
|
await gotoPage(iframeWindow, page);
|
||||||
|
// 等待页面跳转完成
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[HighlightSelectText] 页面跳转失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 搜索并高亮新文本
|
||||||
|
if (newText && newText.trim() !== '') {
|
||||||
|
console.log('[HighlightSelectText] 高亮新文本:', newText);
|
||||||
|
highlightText(iframeWindow, newText, color);
|
||||||
|
|
||||||
|
// 4. 取消选中,避免影响用户后续操作
|
||||||
|
escapeSelection(iframeWindow);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[HighlightSelectText] 切换完成');
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Collabora 功能模块说明
|
||||||
|
|
||||||
|
本目录包含按功能拆分的 Collabora UNO 命令封装模块。
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── README.md # 本说明文档
|
||||||
|
├── index.ts # 统一导出所有功能模块
|
||||||
|
├── search.ts # 搜索功能
|
||||||
|
├── replace.ts # 替换功能
|
||||||
|
├── highlight.ts # 高亮功能
|
||||||
|
├── navigation.ts # 导航/跳转功能
|
||||||
|
├── zoom.ts # 缩放功能
|
||||||
|
├── document.ts # 文档操作
|
||||||
|
├── pageInfo.ts # 页数信息获取
|
||||||
|
└── gotoPage.ts # 自定义页面跳转(不使用 UNO 命令)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 功能模块
|
||||||
|
|
||||||
|
### 1. search.ts - 搜索功能
|
||||||
|
- `unoSearchText(iframeWindow, text)` - 搜索文本
|
||||||
|
|
||||||
|
### 2. replace.ts - 替换功能
|
||||||
|
- `unoReplaceText(iframeWindow, searchText, replaceText)` - 替换文本
|
||||||
|
|
||||||
|
### 3. highlight.ts - 高亮功能
|
||||||
|
- `unoHighlightText(iframeWindow, text, color)` - 高亮文本
|
||||||
|
- `unoRemoveHighlight(iframeWindow, text)` - 移除高亮
|
||||||
|
- `unoEscape(iframeWindow)` - 取消(Escape)
|
||||||
|
|
||||||
|
### 4. navigation.ts - 导航/跳转功能
|
||||||
|
- `unoScrollToTop(iframeWindow)` - 滚动到文档开头(带焦点请求)
|
||||||
|
|
||||||
|
### 5. zoom.ts - 缩放功能
|
||||||
|
- `unoZoomPlus(iframeWindow)` - 放大文档
|
||||||
|
- `unoZoomMinus(iframeWindow)` - 缩小文档
|
||||||
|
- `unoSetZoom(iframeWindow, percentage)` - 设置缩放比例
|
||||||
|
|
||||||
|
### 6. document.ts - 文档操作
|
||||||
|
- `unoSave(iframeWindow)` - 保存文档
|
||||||
|
|
||||||
|
### 7. pageInfo.ts - 页数信息获取
|
||||||
|
- `listenPageNumberChanged(iframeWindow, callback)` - 监听文档页数变化事件
|
||||||
|
- `requestPageInfo(iframeWindow)` - 请求页数信息(返回 Promise)
|
||||||
|
- `getPageInfoFromCollabora()` - 从 Collabora 内部直接获取页数(仅 iframe 内部可用)
|
||||||
|
- `PageInfo` 接口 - 页数信息类型定义
|
||||||
|
|
||||||
|
### 8. gotoPage.ts - 自定义页面跳转(不使用 UNO 命令)
|
||||||
|
- `customGotoPage(iframeWindow, pageNumber)` - 跳转到指定页面(使用自定义 PostMessage 协议)
|
||||||
|
- `GotoPageResponse` 接口 - 页面跳转响应信息类型定义
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
|
||||||
|
### 方式 1: 从统一入口导入(推荐)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
unoSearchText,
|
||||||
|
unoReplaceText,
|
||||||
|
unoHighlightText,
|
||||||
|
unoScrollToTop,
|
||||||
|
unoSave,
|
||||||
|
customGotoPage,
|
||||||
|
requestPageInfo,
|
||||||
|
} from '~/components/collabora/lib';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式 2: 从具体模块导入
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { unoSearchText } from '~/components/collabora/lib/search';
|
||||||
|
import { unoScrollToTop } from '~/components/collabora/lib/navigation';
|
||||||
|
import { customGotoPage } from '~/components/collabora/lib/gotoPage';
|
||||||
|
import { requestPageInfo } from '~/components/collabora/lib/pageInfo';
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心工具函数
|
||||||
|
|
||||||
|
核心的命令发送和状态监听函数位于 `../Uno.ts`:
|
||||||
|
|
||||||
|
- `sendUnoCommand(iframeWindow, command, args)` - 发送 UNO 命令
|
||||||
|
- `unoGetState(iframeWindow)` - 获取文档状态(用于检测命令队列完成)
|
||||||
|
|
||||||
|
## 设计原则
|
||||||
|
|
||||||
|
1. **单一职责**: 每个文件只负责一个功能领域
|
||||||
|
2. **清晰命名**: 文件名直接反映功能(search, replace, highlight 等)
|
||||||
|
3. **统一接口**: 所有函数第一个参数都是 `iframeWindow: Window`
|
||||||
|
4. **依赖注入**: 通过 import `sendUnoCommand` 而不是重复实现
|
||||||
|
5. **便于维护**: 功能独立,修改某个模块不影响其他模块
|
||||||
|
|
||||||
|
## 扩展指南
|
||||||
|
|
||||||
|
如果需要添加新功能模块:
|
||||||
|
|
||||||
|
1. 在 `lib/` 下创建新文件,如 `lib/print.ts`
|
||||||
|
2. 实现功能函数,import `sendUnoCommand` from `../Uno`
|
||||||
|
3. 在 `lib/index.ts` 中添加导出
|
||||||
|
4. 在本 README 中补充说明
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// lib/print.ts
|
||||||
|
import { sendUnoCommand } from '../Uno';
|
||||||
|
|
||||||
|
export function unoPrint(iframeWindow: Window): void {
|
||||||
|
sendUnoCommand(iframeWindow, '.uno:Print', {});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// lib/index.ts
|
||||||
|
export { unoPrint } from './print';
|
||||||
|
```
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* Collabora 文档保存功能模块
|
||||||
|
*
|
||||||
|
* 使用 PostMessage 协议实现文档保存,支持保存参数配置和状态监听
|
||||||
|
*
|
||||||
|
* @encoding UTF-8
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存选项接口
|
||||||
|
*/
|
||||||
|
export interface SaveOptions {
|
||||||
|
/**
|
||||||
|
* 保存后不关闭编辑模式
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
dontTerminateEdit?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 如果文档未修改则不保存
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
dontSaveIfUnmodified?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否显示保存通知
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
notify?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扩展数据(可选)
|
||||||
|
*/
|
||||||
|
extendedData?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存响应接口
|
||||||
|
*/
|
||||||
|
export interface SaveResponse {
|
||||||
|
/**
|
||||||
|
* 是否保存成功
|
||||||
|
*/
|
||||||
|
success: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结果代码(失败时提供)
|
||||||
|
*/
|
||||||
|
result?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错误消息(失败时提供)
|
||||||
|
*/
|
||||||
|
errorMsg?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存时间戳
|
||||||
|
*/
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collabora PostMessage 数据类型
|
||||||
|
*/
|
||||||
|
interface CollaboraMessageData {
|
||||||
|
MessageId?: string;
|
||||||
|
msgId?: string;
|
||||||
|
Values?: {
|
||||||
|
success?: boolean;
|
||||||
|
result?: string;
|
||||||
|
errorMsg?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
args?: {
|
||||||
|
success?: boolean;
|
||||||
|
result?: string;
|
||||||
|
errorMsg?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 PostMessage 数据
|
||||||
|
* @param data - PostMessage 原始数据
|
||||||
|
* @returns 解析后的消息对象
|
||||||
|
*/
|
||||||
|
function parseMessageData(data: unknown): CollaboraMessageData {
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
return JSON.parse(data) as CollaboraMessageData;
|
||||||
|
}
|
||||||
|
return data as CollaboraMessageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存文档
|
||||||
|
*
|
||||||
|
* 使用 PostMessage 协议发送 Action_Save 命令,并监听 Action_Save_Resp 响应。
|
||||||
|
* 支持自定义保存参数,如保存后是否关闭编辑、是否显示通知等。
|
||||||
|
*
|
||||||
|
* @param iframeWindow - Collabora iframe 的 contentWindow
|
||||||
|
* @param options - 保存选项(可选)
|
||||||
|
* @returns Promise,解析为保存响应信息
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // 基本保存
|
||||||
|
* const result = await saveDocument(iframeWindow);
|
||||||
|
* if (result.success) {
|
||||||
|
* console.log('保存成功');
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // 带参数保存
|
||||||
|
* const result = await saveDocument(iframeWindow, {
|
||||||
|
* dontTerminateEdit: true, // 保存后不关闭编辑
|
||||||
|
* dontSaveIfUnmodified: true, // 未修改则不保存
|
||||||
|
* notify: true // 显示通知
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function saveDocument(
|
||||||
|
iframeWindow: Window,
|
||||||
|
options?: SaveOptions
|
||||||
|
): Promise<SaveResponse> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// 超时设置(10秒,保存操作可能需要较长时间)
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('保存操作超时'));
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息监听器
|
||||||
|
*/
|
||||||
|
const handleMessage = (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
// 验证消息来源
|
||||||
|
if (event.source !== iframeWindow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析消息数据
|
||||||
|
const data = parseMessageData(event.data);
|
||||||
|
|
||||||
|
// 监听 Action_Save_Resp 响应
|
||||||
|
// bundle.js 中的响应格式: { MessageId: 'Action_Save_Resp', args: { success, result, errorMsg } }
|
||||||
|
if (data.MessageId === 'Action_Save_Resp' || data.msgId === 'Action_Save_Resp') {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
// 获取响应数据(可能在 Values 或 args 中)
|
||||||
|
const responseData = data.Values || data.args || {};
|
||||||
|
const success = responseData.success === true;
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// 保存成功
|
||||||
|
const response: SaveResponse = {
|
||||||
|
success: true,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
resolve(response);
|
||||||
|
} else {
|
||||||
|
// 保存失败
|
||||||
|
const response: SaveResponse = {
|
||||||
|
success: false,
|
||||||
|
result: responseData.result as string | undefined,
|
||||||
|
errorMsg: responseData.errorMsg as string | undefined,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
resolve(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
cleanup();
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理函数
|
||||||
|
*/
|
||||||
|
const cleanup = () => {
|
||||||
|
window.removeEventListener('message', handleMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 注册消息监听器
|
||||||
|
window.addEventListener('message', handleMessage);
|
||||||
|
|
||||||
|
// 构建保存请求消息
|
||||||
|
const message = {
|
||||||
|
MessageId: 'Action_Save',
|
||||||
|
SendTime: Date.now(),
|
||||||
|
Values: {
|
||||||
|
DontTerminateEdit: options?.dontTerminateEdit || false,
|
||||||
|
DontSaveIfUnmodified: options?.dontSaveIfUnmodified || false,
|
||||||
|
Notify: options?.notify !== false, // 默认显示通知
|
||||||
|
ExtendedData: options?.extendedData || '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[DocumentSave] 发送保存命令:', message);
|
||||||
|
|
||||||
|
// 发送保存请求
|
||||||
|
iframeWindow.postMessage(JSON.stringify(message), '*');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Collabora 自定义页面跳转模块
|
||||||
|
*
|
||||||
|
* 使用自定义 PostMessage 协议实现页面跳转,不依赖 UNO 命令
|
||||||
|
*
|
||||||
|
* @encoding UTF-8
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面跳转响应接口
|
||||||
|
*/
|
||||||
|
export interface GotoPageResponse {
|
||||||
|
pageNumber: number;
|
||||||
|
pageIndex: number;
|
||||||
|
totalPages: number;
|
||||||
|
currentOffset: number;
|
||||||
|
targetOffset: number;
|
||||||
|
scrollDelta: number;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collabora PostMessage 数据类型
|
||||||
|
*/
|
||||||
|
interface CollaboraMessageData {
|
||||||
|
MessageId?: string;
|
||||||
|
msgId?: string;
|
||||||
|
Values?: {
|
||||||
|
Command?: string;
|
||||||
|
Status?: string;
|
||||||
|
pageNumber?: number;
|
||||||
|
pageIndex?: number;
|
||||||
|
totalPages?: number;
|
||||||
|
currentOffset?: number;
|
||||||
|
targetOffset?: number;
|
||||||
|
scrollDelta?: number;
|
||||||
|
timestamp?: number;
|
||||||
|
Error?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
args?: {
|
||||||
|
Command?: string;
|
||||||
|
Status?: string;
|
||||||
|
pageNumber?: number;
|
||||||
|
pageIndex?: number;
|
||||||
|
totalPages?: number;
|
||||||
|
currentOffset?: number;
|
||||||
|
targetOffset?: number;
|
||||||
|
scrollDelta?: number;
|
||||||
|
timestamp?: number;
|
||||||
|
Error?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 PostMessage 数据
|
||||||
|
*/
|
||||||
|
function parseMessageData(data: unknown): CollaboraMessageData {
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
return JSON.parse(data) as CollaboraMessageData;
|
||||||
|
}
|
||||||
|
return data as CollaboraMessageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跳转到指定页面 (自定义实现,不使用 UNO 命令)
|
||||||
|
*
|
||||||
|
* 使用自定义 custompostMessage 插件实现页面跳转。
|
||||||
|
* 注意: Collabora 的页码是从 0 开始的,但此函数接受从 1 开始的页码。
|
||||||
|
*
|
||||||
|
* @param iframeWindow - iframe 的 contentWindow
|
||||||
|
* @param pageNumber - 页码 (从1开始)
|
||||||
|
* @returns Promise,解析为跳转响应信息
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const response = await customGotoPage(iframeWindow, 5);
|
||||||
|
* console.log(`成功跳转到第 ${response.pageNumber} 页`);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function customGotoPage(
|
||||||
|
iframeWindow: Window,
|
||||||
|
pageNumber: number
|
||||||
|
): Promise<GotoPageResponse> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('页面跳转超时'));
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
const handleMessage = (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
if (event.source !== iframeWindow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parseMessageData(event.data);
|
||||||
|
|
||||||
|
// bundle.js 的 _postMessage 会将消息转换为:
|
||||||
|
// { MessageId: 'custompostMessage_Resp', Values: { Command: 'GOTO_PAGE', ... } }
|
||||||
|
if (
|
||||||
|
data.MessageId === 'custompostMessage_Resp' &&
|
||||||
|
data.Values?.Command === 'GOTO_PAGE'
|
||||||
|
) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
if (data.Values.Status === 'success') {
|
||||||
|
const response: GotoPageResponse = {
|
||||||
|
pageNumber: data.Values.pageNumber || pageNumber,
|
||||||
|
pageIndex: data.Values.pageIndex || pageNumber - 1,
|
||||||
|
totalPages: data.Values.totalPages || 0,
|
||||||
|
currentOffset: data.Values.currentOffset || 0,
|
||||||
|
targetOffset: data.Values.targetOffset || 0,
|
||||||
|
scrollDelta: data.Values.scrollDelta || 0,
|
||||||
|
timestamp: data.Values.timestamp || Date.now(),
|
||||||
|
};
|
||||||
|
resolve(response);
|
||||||
|
} else {
|
||||||
|
reject(new Error(data.Values.Error || '页面跳转失败'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
cleanup();
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
window.removeEventListener('message', handleMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', handleMessage);
|
||||||
|
|
||||||
|
// 发送跳转请求消息
|
||||||
|
const message = {
|
||||||
|
MessageId: 'custompostMessage',
|
||||||
|
Values: {
|
||||||
|
Command: 'GOTO_PAGE',
|
||||||
|
Args: {
|
||||||
|
pageNumber: pageNumber,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
iframeWindow.postMessage(JSON.stringify(message), '*');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Collabora 功能模块统一导出
|
||||||
|
*
|
||||||
|
* @encoding UTF-8
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
// 高亮,已经封装好了
|
||||||
|
export { switchHighlight } from './Highlightselecttext';
|
||||||
|
|
||||||
|
// 导航/跳转功能
|
||||||
|
export {
|
||||||
|
unoScrollToTop
|
||||||
|
} from './navigation';
|
||||||
|
|
||||||
|
|
||||||
|
// 页数信息
|
||||||
|
export {
|
||||||
|
requestPageInfo,
|
||||||
|
type PageInfo
|
||||||
|
} from './pageInfo';
|
||||||
|
|
||||||
|
// 自定义页面跳转功能
|
||||||
|
export {
|
||||||
|
customGotoPage,
|
||||||
|
type GotoPageResponse
|
||||||
|
} from './gotoPage';
|
||||||
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Collabora 导航/跳转功能模块
|
||||||
|
*
|
||||||
|
* @encoding UTF-8
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sendUnoCommand } from '../Uno';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 滚动到文档开头 (带焦点请求)
|
||||||
|
* @param iframeWindow - iframe 的 contentWindow
|
||||||
|
*/
|
||||||
|
export async function unoScrollToTop(iframeWindow: Window): Promise<void> {
|
||||||
|
// 1. 先请求 iframe 获取焦点
|
||||||
|
const focusMessage = {
|
||||||
|
MessageId: 'custompostMessage',
|
||||||
|
Values: {
|
||||||
|
Command: 'REQUEST_FOCUS',
|
||||||
|
Args: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
console.log('[custompostMessage] 请求焦点 (滚动到顶部)');
|
||||||
|
iframeWindow.postMessage(JSON.stringify(focusMessage), '*');
|
||||||
|
|
||||||
|
// 2. 等待焦点激活
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// 3. 发送滚动命令,要发三次,有时候卡在表格里面就是需要多发几次
|
||||||
|
sendUnoCommand(iframeWindow, '.uno:GoToStartOfDoc', {});
|
||||||
|
sendUnoCommand(iframeWindow, '.uno:GoToStartOfDoc', {});
|
||||||
|
sendUnoCommand(iframeWindow, '.uno:GoToStartOfDoc', {});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Collabora 页数信息获取模块
|
||||||
|
*
|
||||||
|
* @encoding UTF-8
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页数信息接口
|
||||||
|
*/
|
||||||
|
export interface PageInfo {
|
||||||
|
totalPages: number;
|
||||||
|
currentPage: number;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collabora PostMessage 数据类型
|
||||||
|
*/
|
||||||
|
interface CollaboraMessageData {
|
||||||
|
MessageId?: string;
|
||||||
|
msgId?: string;
|
||||||
|
// bundle.js _postMessage 发送的格式
|
||||||
|
Values?: {
|
||||||
|
Command?: string;
|
||||||
|
Status?: string;
|
||||||
|
totalPages?: number;
|
||||||
|
currentPage?: number;
|
||||||
|
timestamp?: number;
|
||||||
|
pages?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
// 原始插件返回的格式
|
||||||
|
args?: {
|
||||||
|
Command?: string;
|
||||||
|
Status?: string;
|
||||||
|
totalPages?: number;
|
||||||
|
currentPage?: number;
|
||||||
|
timestamp?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 PostMessage 数据
|
||||||
|
*/
|
||||||
|
function parseMessageData(data: unknown): CollaboraMessageData {
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
return JSON.parse(data) as CollaboraMessageData;
|
||||||
|
}
|
||||||
|
return data as CollaboraMessageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 PostMessage 请求页数信息
|
||||||
|
*
|
||||||
|
* 使用自定义 custompostMessage 插件获取文档页数。
|
||||||
|
* 注意: Collabora 的页码是从 0 开始的。
|
||||||
|
*
|
||||||
|
* @param iframeWindow - iframe 的 contentWindow
|
||||||
|
* @returns Promise,解析为页数信息
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const info = await requestPageInfo(iframeWindow);
|
||||||
|
* console.log(`文档共 ${info.totalPages} 页,当前在第 ${info.currentPage + 1} 页`);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function requestPageInfo(iframeWindow: Window): Promise<PageInfo> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('请求页数信息超时'));
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
const handleMessage = (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
if (event.source !== iframeWindow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parseMessageData(event.data);
|
||||||
|
|
||||||
|
// bundle.js 的 _postMessage 会将消息转换为:
|
||||||
|
// { MessageId: 'custompostMessage_Resp', Values: { Command: 'GET_PAGE_INFO', ... } }
|
||||||
|
// 所以我们需要监听 MessageId 而不是 msgId
|
||||||
|
if (
|
||||||
|
data.MessageId === 'custompostMessage_Resp' &&
|
||||||
|
data.Values?.Command === 'GET_PAGE_INFO' &&
|
||||||
|
data.Values?.Status === 'success'
|
||||||
|
) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
const info: PageInfo = {
|
||||||
|
totalPages: data.Values.totalPages || 0,
|
||||||
|
currentPage: data.Values.currentPage || 0,
|
||||||
|
timestamp: data.Values.timestamp || Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
resolve(info);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
cleanup();
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
window.removeEventListener('message', handleMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', handleMessage);
|
||||||
|
|
||||||
|
// 发送请求消息
|
||||||
|
const message = {
|
||||||
|
MessageId: 'custompostMessage',
|
||||||
|
Values: {
|
||||||
|
Command: 'GET_PAGE_INFO',
|
||||||
|
Args: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
iframeWindow.postMessage(JSON.stringify(message), '*');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// /**
|
||||||
|
// * Collabora 缩放功能模块
|
||||||
|
// *
|
||||||
|
// * @encoding UTF-8
|
||||||
|
// */
|
||||||
|
|
||||||
|
// import { sendUnoCommand } from '../Uno';
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * 放大文档(固定步长)
|
||||||
|
// * @param iframeWindow - iframe 的 contentWindow
|
||||||
|
// */
|
||||||
|
// export function unoZoomPlus(iframeWindow: Window): void {
|
||||||
|
// sendUnoCommand(iframeWindow, '.uno:ZoomPlus', {});
|
||||||
|
// }
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * 缩小文档(固定步长)
|
||||||
|
// * @param iframeWindow - iframe 的 contentWindow
|
||||||
|
// */
|
||||||
|
// export function unoZoomMinus(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: 'short',
|
||||||
|
// value: percentage,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// }
|
||||||
@@ -35,3 +35,29 @@ export interface CollaboraViewerProps {
|
|||||||
/** 用户名称 */
|
/** 用户名称 */
|
||||||
userName?: string;
|
userName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CollaboraViewer 暴露给父组件的方法接口
|
||||||
|
*/
|
||||||
|
export interface CollaboraViewerHandle {
|
||||||
|
/** UNO 命令方法集合 */
|
||||||
|
unoCommands: {
|
||||||
|
searchText: (text: string) => Promise<void>;
|
||||||
|
locateText: (text: string) => Promise<void>;
|
||||||
|
replaceText: (searchText: string, replaceText: string) => Promise<void>;
|
||||||
|
highlightText: (text: string, color?: number) => Promise<void>;
|
||||||
|
removeHighlight: (text: string) => Promise<void>;
|
||||||
|
escapeSelection: () => Promise<void>;
|
||||||
|
scrollToTop: () => Promise<void>;
|
||||||
|
saveDocument: () => Promise<void>;
|
||||||
|
zoomIn: () => Promise<void>;
|
||||||
|
zoomOut: () => Promise<void>;
|
||||||
|
setZoom: (percentage: number) => Promise<void>;
|
||||||
|
};
|
||||||
|
/** 文档是否已加载完成 */
|
||||||
|
isReady: boolean;
|
||||||
|
/** 当前模式 */
|
||||||
|
mode: 'view' | 'edit';
|
||||||
|
/** 获取 iframe 的 contentWindow (用于发送 PostMessage) */
|
||||||
|
getIframeWindow: () => Window | null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,43 +3,13 @@
|
|||||||
* 显示文档内容和评查点高亮
|
* 显示文档内容和评查点高亮
|
||||||
*/
|
*/
|
||||||
import { useState, useEffect, useRef, ChangeEvent } from 'react';
|
import { useState, useEffect, useRef, ChangeEvent } from 'react';
|
||||||
import { Document, Page, pdfjs } from 'react-pdf';
|
import { CollaboraViewer, type CollaboraViewerHandle } from '~/components/collabora/CollaboraViewer';
|
||||||
import { DOCUMENT_URL } from '~/api/axios-client';
|
import { requestPageInfo, customGotoPage } from '~/components/collabora/lib';
|
||||||
import { CollaboraViewer } from '~/components/collabora/CollaboraViewer';
|
import { PdfPreview } from './previewComponents/PdfPreview';
|
||||||
|
|
||||||
// 导入react-pdf的CSS样式(文本层和注释层必需)
|
|
||||||
import 'react-pdf/dist/esm/Page/TextLayer.css';
|
|
||||||
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
|
|
||||||
|
|
||||||
// 设置worker路径为public目录下的worker文件
|
|
||||||
// 使用已经下载的兼容版本 (pdfjs-dist v2.12.313)
|
|
||||||
// 2025/09/28 使用新版本的pdfjs-dist v4.8.69
|
|
||||||
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
|
|
||||||
|
|
||||||
// 导入统一的ReviewPoint类型
|
|
||||||
import { type ReviewPoint } from './';
|
|
||||||
import { toastService } from '../ui/Toast';
|
import { toastService } from '../ui/Toast';
|
||||||
|
|
||||||
/**
|
// 直接从ReviewPointsList导入类型,避免循环依赖
|
||||||
* 自定义样式
|
import { type ReviewPoint } from './ReviewPointsList';
|
||||||
* 这些样式解决了PDF页面在放大时互相重叠的问题
|
|
||||||
*/
|
|
||||||
const styles = {
|
|
||||||
pdfContainer: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column' as const,
|
|
||||||
alignItems: 'center',
|
|
||||||
width: '100%',
|
|
||||||
position: 'relative' as const,
|
|
||||||
},
|
|
||||||
pageContainer: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column' as const,
|
|
||||||
alignItems: 'center',
|
|
||||||
width: '100%',
|
|
||||||
position: 'relative' as const,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 定义文档内容类型
|
// 定义文档内容类型
|
||||||
interface FileContent {
|
interface FileContent {
|
||||||
@@ -77,6 +47,7 @@ interface FilePreviewProps {
|
|||||||
reviewPoints?: ReviewPoint[]; // 设为可选
|
reviewPoints?: ReviewPoint[]; // 设为可选
|
||||||
activeReviewPointResultId: string | null;
|
activeReviewPointResultId: string | null;
|
||||||
targetPage?: number; // 新增目标页码参数
|
targetPage?: number; // 新增目标页码参数
|
||||||
|
charPositions?: Array<{ box: number[][], char: string, score: number }>; // 字符位置信息
|
||||||
isStructuredView?: boolean; // 是否显示结构化视图
|
isStructuredView?: boolean; // 是否显示结构化视图
|
||||||
userInfo?: {
|
userInfo?: {
|
||||||
sub: string;
|
sub: string;
|
||||||
@@ -85,32 +56,174 @@ interface FilePreviewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// export function FilePreview({ fileContent, reviewPoints, activeReviewPointResultId, targetPage }: FilePreviewProps) {
|
// export function FilePreview({ fileContent, reviewPoints, activeReviewPointResultId, targetPage }: FilePreviewProps) {
|
||||||
export function FilePreview({ fileContent, activeReviewPointResultId, targetPage, isStructuredView = false, userInfo }: FilePreviewProps) {
|
export function FilePreview({ fileContent, activeReviewPointResultId, targetPage, charPositions, isStructuredView = false, userInfo }: FilePreviewProps) {
|
||||||
const [zoomLevel, setZoomLevel] = useState(100);
|
// 获取文件类型
|
||||||
// const [highlightsVisible, setHighlightsVisible] = useState(true);
|
const real_path = fileContent.path || fileContent.template_contract_path || '';
|
||||||
|
const fileExtension = real_path.split('.').pop()?.toLowerCase();
|
||||||
|
const isDocx = fileExtension === 'docx';
|
||||||
|
const isPdf = fileExtension === 'pdf';
|
||||||
|
|
||||||
|
// ✅ 将所有hooks移到条件return之前,确保遵守React Hooks规则
|
||||||
|
// Refs
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
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 [numPages, setNumPages] = useState<number | null>(null);
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
|
||||||
const [pageInputValue, setPageInputValue] = useState<string>('');
|
const [pageInputValue, setPageInputValue] = useState<string>('');
|
||||||
|
const [dragMode, setDragMode] = useState(false);
|
||||||
// 拖拽状态管理
|
|
||||||
const [dragMode, setDragMode] = useState(false); // 是否处于拖拽模式
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [dragCursor, setDragCursor] = useState('default');
|
const [dragCursor, setDragCursor] = useState('default');
|
||||||
const lastMousePosRef = useRef({ x: 0, y: 0 });
|
|
||||||
|
// ✅ 将所有useEffect移到条件return之前
|
||||||
// 放大文档
|
// DOCX 页数获取: 使用 requestPageInfo 方法
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDocx || isPdf) return; // PDF文件不需要执行
|
||||||
|
|
||||||
|
let intervalCleared = false;
|
||||||
|
|
||||||
|
// 等待 CollaboraViewer 准备就绪
|
||||||
|
const checkInterval = setInterval(() => {
|
||||||
|
if (intervalCleared) return;
|
||||||
|
|
||||||
|
if (!collaboraViewerRef.current?.isReady) {
|
||||||
|
console.log('[FilePreview] 等待 Collabora 就绪...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
intervalCleared = true;
|
||||||
|
|
||||||
|
const iframeWindow = collaboraViewerRef.current.getIframeWindow?.();
|
||||||
|
if (!iframeWindow) {
|
||||||
|
console.warn('[FilePreview] 无法获取 iframe window');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 requestPageInfo 获取页数
|
||||||
|
requestPageInfo(iframeWindow)
|
||||||
|
.then((info) => {
|
||||||
|
setNumPages(info.totalPages);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn('[FilePreview] 获取 DOCX 页数失败:', error.message);
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
// 清理定时器
|
||||||
|
return () => {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
};
|
||||||
|
}, [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(() => {
|
||||||
|
if (isPdf) return; // PDF由PdfPreview处理
|
||||||
|
|
||||||
|
// 如果有目标页码,并且与上次相同,提示用户
|
||||||
|
if(targetPage && numPages && targetPage <= numPages && targetPage === prevTargetPageRef.current){
|
||||||
|
// toastService.success(`已跳转至目标页码`);
|
||||||
|
}
|
||||||
|
// 如果有目标页码,并且与上次不同或activeReviewPointId变化了,则执行跳转
|
||||||
|
if (targetPage && numPages && targetPage <= numPages) {
|
||||||
|
prevTargetPageRef.current = targetPage;
|
||||||
|
let newTargetPage = targetPage;
|
||||||
|
|
||||||
|
// 页码偏移量
|
||||||
|
try {
|
||||||
|
// 安全地访问ocrResult
|
||||||
|
if (fileContent.ocrResult && fileContent.ocrResult.__meta && fileContent.ocrResult.__meta.page_offset) {
|
||||||
|
// 可以根据需要使用page_offset调整目标页面
|
||||||
|
newTargetPage = targetPage + fileContent.ocrResult.__meta.page_offset;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("访问ocrResult时出错:", error);
|
||||||
|
toastService.error("访问ocrResult时出错:" + (error instanceof Error ? error.message : '未知错误'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageElementId = `page-${newTargetPage}${isStructuredView ? '-structured' : ''}`;
|
||||||
|
|
||||||
|
const pageElement = document.getElementById(pageElementId);
|
||||||
|
if (pageElement) {
|
||||||
|
pageElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
} else {
|
||||||
|
console.warn(`未找到页面元素: ${pageElementId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [targetPage, numPages, fileContent, activeReviewPointResultId, isStructuredView, isPdf]);
|
||||||
|
|
||||||
|
// 调试日志
|
||||||
|
console.log('[FilePreview] 组件渲染', {
|
||||||
|
real_path,
|
||||||
|
fileExtension,
|
||||||
|
isDocx,
|
||||||
|
isPdf,
|
||||||
|
hasPath: !!fileContent.path,
|
||||||
|
hasTemplatePath: !!fileContent.template_contract_path
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果是PDF文件,直接使用PdfPreview组件
|
||||||
|
if (isPdf && real_path) {
|
||||||
|
console.log('[FilePreview] 渲染PDF预览', { real_path, targetPage, charPositions });
|
||||||
|
const pageOffset = fileContent.ocrResult?.__meta?.page_offset || 0;
|
||||||
|
return (
|
||||||
|
<PdfPreview
|
||||||
|
filePath={real_path}
|
||||||
|
targetPage={targetPage}
|
||||||
|
charPositions={charPositions}
|
||||||
|
isStructuredView={isStructuredView}
|
||||||
|
activeReviewPointResultId={activeReviewPointResultId}
|
||||||
|
pageOffset={pageOffset}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOCX 和其他文件类型继续使用原有逻辑
|
||||||
|
|
||||||
|
// 放大文档(仅用于 DOCX)
|
||||||
const handleZoomIn = () => {
|
const handleZoomIn = () => {
|
||||||
if (zoomLevel < 200) {
|
if (!collaboraViewerRef.current?.isReady) {
|
||||||
setZoomLevel(prevZoom => prevZoom + 10);
|
toastService.warning('文档尚未加载完成,请稍候...');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
collaboraViewerRef.current?.unoCommands.zoomIn();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 缩小文档
|
// 缩小文档(仅用于 DOCX)
|
||||||
const handleZoomOut = () => {
|
const handleZoomOut = () => {
|
||||||
if (zoomLevel > 50) {
|
if (!collaboraViewerRef.current?.isReady) {
|
||||||
setZoomLevel(prevZoom => prevZoom - 10);
|
toastService.warning('文档尚未加载完成,请稍候...');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
collaboraViewerRef.current?.unoCommands.zoomOut();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 切换拖拽模式
|
// 切换拖拽模式
|
||||||
@@ -164,66 +277,7 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage
|
|||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
setDragCursor('grab');
|
setDragCursor('grab');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 监听鼠标离开窗口事件
|
|
||||||
useEffect(() => {
|
|
||||||
const handleMouseLeave = () => {
|
|
||||||
if (dragMode && isDragging) {
|
|
||||||
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]);
|
|
||||||
|
|
||||||
// 处理页面跳转
|
|
||||||
const prevTargetPageRef = useRef<number | undefined>(undefined);
|
|
||||||
useEffect(() => {
|
|
||||||
// 调试信息:记录组件状态
|
|
||||||
// console.log(`FilePreview更新 - isStructuredView:${isStructuredView}, targetPage:${targetPage}, activeReviewPointResultId:${activeReviewPointResultId}, numPages:${numPages}`);
|
|
||||||
|
|
||||||
// 如果有目标页码,并且与上次相同,提示用户
|
|
||||||
if(targetPage && numPages && targetPage <= numPages && targetPage === prevTargetPageRef.current){
|
|
||||||
// toastService.success(`已跳转至目标页码`);
|
|
||||||
}
|
|
||||||
// 如果有目标页码,并且与上次不同或activeReviewPointId变化了,则执行跳转
|
|
||||||
if (targetPage && numPages && targetPage <= numPages) {
|
|
||||||
// if (targetPage && numPages && targetPage <= numPages && (targetPage !== prevTargetPageRef.current || activeReviewPointResultId)) {
|
|
||||||
prevTargetPageRef.current = targetPage;
|
|
||||||
let newTargetPage = targetPage;
|
|
||||||
|
|
||||||
// 页码偏移量
|
|
||||||
try {
|
|
||||||
// 安全地访问ocrResult
|
|
||||||
if (fileContent.ocrResult && fileContent.ocrResult.__meta && fileContent.ocrResult.__meta.page_offset) {
|
|
||||||
// 可以根据需要使用page_offset调整目标页面
|
|
||||||
newTargetPage = targetPage + fileContent.ocrResult.__meta.page_offset;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("访问ocrResult时出错:", error);
|
|
||||||
toastService.error("访问ocrResult时出错:" + (error instanceof Error ? error.message : '未知错误'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageElementId = `page-${newTargetPage}${isStructuredView ? '-structured' : ''}`;
|
|
||||||
// console.log(`尝试跳转到元素ID: ${pageElementId}`);
|
|
||||||
|
|
||||||
const pageElement = document.getElementById(pageElementId);
|
|
||||||
if (pageElement) {
|
|
||||||
// console.log(`跳转到第${newTargetPage}页,对应评查点结果ID: ${activeReviewPointResultId}`);
|
|
||||||
pageElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
} else {
|
|
||||||
console.warn(`未找到页面元素: ${pageElementId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [targetPage, numPages, fileContent, activeReviewPointResultId, isStructuredView]);
|
|
||||||
|
|
||||||
// 获取评查点对应的样式类
|
// 获取评查点对应的样式类
|
||||||
// const getHighlightClass = (status: string) => {
|
// const getHighlightClass = (status: string) => {
|
||||||
// switch (status) {
|
// switch (status) {
|
||||||
@@ -245,22 +299,28 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage
|
|||||||
setPageInputValue(value);
|
setPageInputValue(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理页码跳转
|
// 处理页码跳转(仅用于 DOCX)
|
||||||
const handlePageJump = () => {
|
const handlePageJump = async () => {
|
||||||
if (!pageInputValue || !numPages) return;
|
if (!pageInputValue) return;
|
||||||
|
|
||||||
const targetPageNum = parseInt(pageInputValue, 10);
|
const targetPageNum = parseInt(pageInputValue, 10);
|
||||||
|
const iframeWindow = collaboraViewerRef.current?.getIframeWindow?.();
|
||||||
// 验证页码是否在有效范围内
|
|
||||||
if (targetPageNum > 0 && targetPageNum <= numPages) {
|
if (!iframeWindow) {
|
||||||
// 找到目标页面元素并滚动到该位置
|
toastService.warning('文档尚未加载完成,请稍候...');
|
||||||
const pageElement = document.getElementById(`page-${targetPageNum}`);
|
return;
|
||||||
if (pageElement) {
|
}
|
||||||
pageElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
|
if (targetPageNum > 0) {
|
||||||
|
try {
|
||||||
|
await customGotoPage(iframeWindow, targetPageNum);
|
||||||
|
setPageInputValue('');
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
||||||
|
toastService.error(`跳转失败: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 页码超出范围,显示错误信息或重置输入
|
toastService.warning('请输入有效页码');
|
||||||
toastService.warning(`请输入有效页码 (1-${numPages})`);
|
|
||||||
setPageInputValue('');
|
setPageInputValue('');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -271,126 +331,18 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage
|
|||||||
handlePageJump();
|
handlePageJump();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// PDF文档加载成功回调函数
|
|
||||||
function onDocumentLoadSuccess({ numPages }: { numPages: number }) {
|
|
||||||
setNumPages(numPages);
|
|
||||||
// console.log("PDF加载成功,页数:", numPages);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算页面在缩放后的实际间距
|
|
||||||
const calculatePageMargin = (zoomFactor: number) => {
|
|
||||||
// 基础间距为30px,随着缩放倍数线性增加
|
|
||||||
const baseMargin = 30;
|
|
||||||
// 页面缩放后,需要额外添加的间距 = (缩放倍数 - 1) * 页面高度
|
|
||||||
const additionalMargin = Math.max(0, (zoomFactor - 1) * 800); // 800是估计的页面高度
|
|
||||||
return baseMargin + additionalMargin;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 滚动到顶部
|
// 滚动到顶部(仅用于 DOCX)
|
||||||
const handleScrollToTop = () => {
|
const handleScrollToTop = () => {
|
||||||
if (contentRef.current) {
|
if (!collaboraViewerRef.current?.isReady) {
|
||||||
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' });
|
toastService.warning('文档尚未加载完成,请稍候...');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
};
|
collaboraViewerRef.current?.unoCommands.scrollToTop();
|
||||||
|
|
||||||
/**
|
|
||||||
* 渲染PDF文档的所有页面
|
|
||||||
*
|
|
||||||
* 功能描述:
|
|
||||||
* 1. 生成PDF所有页面的渲染数组,每个页面包含页码标识和实际页面内容
|
|
||||||
* 2. 处理页面缩放,通过CSS transform实现页面大小调整
|
|
||||||
* 3. 在每个页面上标记对应的评查点高亮区域
|
|
||||||
* 4. 处理评查点的激活状态,显示特殊的高亮效果
|
|
||||||
*
|
|
||||||
* @returns {JSX.Element[] | null} 返回所有页面组件的数组,如果没有页数信息则返回null
|
|
||||||
*/
|
|
||||||
const renderAllPages = () => {
|
|
||||||
// 如果还没有获取到PDF总页数,返回null
|
|
||||||
if (!numPages) return null;
|
|
||||||
|
|
||||||
// 用于存储所有页面组件的数组
|
|
||||||
const pages = [];
|
|
||||||
|
|
||||||
// 遍历每一页,生成对应的页面组件
|
|
||||||
for (let i = 1; i <= numPages; i++) {
|
|
||||||
// 计算当前缩放级别下的页面容器样式
|
|
||||||
const zoomFactor = zoomLevel / 100;
|
|
||||||
const pageContainerStyle = {
|
|
||||||
...styles.pageContainer,
|
|
||||||
marginBottom: `${calculatePageMargin(zoomFactor)}px`, // 动态计算页面间距
|
|
||||||
};
|
|
||||||
|
|
||||||
// 为结构化视图和普通视图创建不同的ID
|
|
||||||
const pageId = isStructuredView ? `page-${i}-structured` : `page-${i}`;
|
|
||||||
|
|
||||||
// 为每一页创建组件
|
|
||||||
pages.push(
|
|
||||||
<div key={i} id={pageId} style={pageContainerStyle}>
|
|
||||||
{/* 页码标识,显示在页面上方 */}
|
|
||||||
<div className="text-center text-gray-500 text-sm mb-2">第 {i} 页</div>
|
|
||||||
|
|
||||||
{/* 页面容器,应用缩放变换,设置相对定位用于放置评查点高亮 */}
|
|
||||||
<div
|
|
||||||
className="page-wrapper"
|
|
||||||
style={{
|
|
||||||
// transform: `scale(${zoomFactor})`, // 根据zoomLevel应用缩放
|
|
||||||
// transformOrigin: 'top center', // 缩放原点设置为顶部中心
|
|
||||||
position: 'relative', // 相对定位,作为评查点高亮的定位参考
|
|
||||||
display: 'inline-block', // 内联块级元素,宽度由内容决定
|
|
||||||
margin: '0 auto', // 水平居中
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 渲染PDF页面组件 */}
|
|
||||||
<Page
|
|
||||||
pageNumber={i} // 当前页码
|
|
||||||
scale={zoomLevel / 100}
|
|
||||||
devicePixelRatio={window.devicePixelRatio || 1} //根据设备像素比渲染
|
|
||||||
renderTextLayer={false} // 停用文本层,使文本可选择
|
|
||||||
renderAnnotationLayer={false} // 停用注释层,显示PDF内置注释
|
|
||||||
className="border border-gray-300 shadow-md" // 添加边框和阴影样式
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 渲染评查点高亮区域 */}
|
|
||||||
{/* {highlightsVisible && pageReviewPoints.map(point => {
|
|
||||||
// 判断当前评查点是否为激活状态(被选中)
|
|
||||||
const isActive = point.id === activeReviewPointId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
// 评查点高亮区域
|
|
||||||
<div
|
|
||||||
key={point.id}
|
|
||||||
// 添加多个类名:基础高亮区域、PDF专用高亮、状态样式类、激活状态类
|
|
||||||
className={`highlight-area pdf-highlight ${getHighlightClass(point.status)} ${isActive ? 'highlight-focus' : ''}`}
|
|
||||||
// 设置唯一标识,用于滚动定位和DOM查询
|
|
||||||
data-review-id={point.id}
|
|
||||||
// 设置高亮区域的样式
|
|
||||||
style={{
|
|
||||||
position: 'absolute', // 绝对定位,相对于页面容器
|
|
||||||
zIndex: isActive ? 20 : 10, // 激活状态时提高层级,确保在最上层显示
|
|
||||||
boxShadow: isActive ? '0 0 0 2px yellow, 0 0 10px rgba(0,0,0,0.3)' : '', // 激活时添加特殊阴影效果
|
|
||||||
// 根据评查点的位置信息计算高亮区域位置,实际项目中需根据真实位置坐标计算
|
|
||||||
top: `${point.position?.index ? point.position.index * 20 : 20}px`,
|
|
||||||
left: '50px',
|
|
||||||
width: '300px',
|
|
||||||
height: '30px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})} */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 返回所有页面组件数组
|
|
||||||
return pages;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 渲染文档内容
|
// 渲染文档内容
|
||||||
const renderDocumentContent = () => {
|
const renderDocumentContent = () => {
|
||||||
const real_path = fileContent.path || fileContent.template_contract_path || '';
|
|
||||||
|
|
||||||
// 如果路径无效,显示错误信息
|
// 如果路径无效,显示错误信息
|
||||||
if (!real_path) {
|
if (!real_path) {
|
||||||
if(!fileContent.template_contract_path){
|
if(!fileContent.template_contract_path){
|
||||||
@@ -407,75 +359,15 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log('real_path',real_path);
|
|
||||||
// 获取文件扩展名
|
|
||||||
const fileExtension = real_path.split('.').pop()?.toLowerCase();
|
|
||||||
|
|
||||||
// PDF内容渲染
|
|
||||||
const renderPdfContent = () => (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
...styles.pdfContainer,
|
|
||||||
// 当缩放大于100%时设置最小宽度,确保出现横向滚动条
|
|
||||||
// minWidth: zoomLevel > 100 ? `${zoomLevel}%` : '100%',
|
|
||||||
width: '100%',
|
|
||||||
overflow: 'visible'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Document
|
|
||||||
file={`/api/pdf-proxy?path=${encodeURIComponent(real_path)}`}
|
|
||||||
onLoadSuccess={onDocumentLoadSuccess}
|
|
||||||
onLoadError={(error) => {
|
|
||||||
console.error("PDF加载错误:", error);
|
|
||||||
setLoadError("PDF文档加载失败:" + (error.message || "未知错误"));
|
|
||||||
}}
|
|
||||||
className="w-full"
|
|
||||||
error={<div className="text-red-500">PDF文档加载失败,请检查链接或网络连接。</div>}
|
|
||||||
noData={<div>无数据</div>}
|
|
||||||
loading={<div className="text-center py-10">PDF加载中...</div>}
|
|
||||||
>
|
|
||||||
{renderAllPages()}
|
|
||||||
</Document>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 结构化数据渲染
|
|
||||||
const renderStructuredData = () => (
|
|
||||||
<div className="structured-view p-4 text-left mt-4 border-t border-gray-200">
|
|
||||||
<div className="text-xs font-medium mb-2">结构化数据:</div>
|
|
||||||
{fileContent.ocrResult ? (
|
|
||||||
<div className="overflow-auto max-h-[300px]">
|
|
||||||
<pre className="text-xs whitespace-pre-wrap bg-gray-50 p-3 rounded">
|
|
||||||
{JSON.stringify(fileContent.ocrResult, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-gray-500 p-4 text-center">
|
|
||||||
<p>无结构化数据可显示</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 根据文件类型选择不同的渲染方式
|
// 根据文件类型选择不同的渲染方式
|
||||||
if (fileExtension === 'pdf') {
|
// 注意:PDF 文件已在组件开头使用 PdfPreview 组件提前返回
|
||||||
// 结构化视图模式:显示PDF和结构化数据
|
if (fileExtension === 'docx') {
|
||||||
if (isStructuredView) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{renderPdfContent()}
|
|
||||||
{renderStructuredData()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// 普通模式:仅显示PDF
|
|
||||||
return renderPdfContent();
|
|
||||||
} else if (fileExtension === 'docx') {
|
|
||||||
// DOCX文件使用Collabora Online预览
|
// DOCX文件使用Collabora Online预览
|
||||||
return (
|
return (
|
||||||
<CollaboraViewer
|
<CollaboraViewer
|
||||||
|
ref={collaboraViewerRef}
|
||||||
fileId={real_path}
|
fileId={real_path}
|
||||||
mode="view"
|
mode="edit"
|
||||||
userId={userInfo?.sub || 'guest'}
|
userId={userInfo?.sub || 'guest'}
|
||||||
userName={userInfo?.nick_name || '访客'}
|
userName={userInfo?.nick_name || '访客'}
|
||||||
/>
|
/>
|
||||||
@@ -547,9 +439,6 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-2 text-xs text-gray-500 hidden sm:hidden md:hidden lg:hidden xl:inline whitespace-nowrap flex-shrink-0">
|
|
||||||
比例:{zoomLevel}%
|
|
||||||
</span>
|
|
||||||
<button
|
<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' : ''}`}
|
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="切换拖拽模式"
|
title="切换拖拽模式"
|
||||||
@@ -608,13 +497,7 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage
|
|||||||
padding: 0
|
padding: 0
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{loadError ? (
|
{renderDocumentContent()}
|
||||||
<div className="text-red-500 p-4">
|
|
||||||
<p>{loadError}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
renderDocumentContent()
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -68,6 +68,16 @@ const getRuleTypeText = (type?: string): string => {
|
|||||||
return ruleTypeMap[type] || type;
|
return ruleTypeMap[type] || type;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字符位置类型定义
|
||||||
|
* 用于定位文档中具体的文字位置
|
||||||
|
*/
|
||||||
|
export interface CharPosition {
|
||||||
|
box: number[][]; // 字符边界框坐标
|
||||||
|
char: string; // 字符内容
|
||||||
|
score: number; // OCR识别置信度
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 评查点类型定义
|
* 评查点类型定义
|
||||||
* 用于展示单个评查结果
|
* 用于展示单个评查结果
|
||||||
@@ -136,7 +146,7 @@ interface ReviewPointsListProps {
|
|||||||
reviewPoints: ReviewPoint[];
|
reviewPoints: ReviewPoint[];
|
||||||
statistics: Statistics;
|
statistics: Statistics;
|
||||||
activeReviewPointResultId: string | null;
|
activeReviewPointResultId: string | null;
|
||||||
onReviewPointSelect: (id: string, page?: number) => void;
|
onReviewPointSelect: (id: string, page?: number, charPositions?: CharPosition[]) => void;
|
||||||
onStatusChange: (id: string, editAuditStatusId: string | number, status: string, message: string) => void;
|
onStatusChange: (id: string, editAuditStatusId: string | number, status: string, message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -816,15 +826,15 @@ export function ReviewPointsList({
|
|||||||
|
|
||||||
// console.log('singleReviewPoint-------', singleReviewPoint);
|
// console.log('singleReviewPoint-------', singleReviewPoint);
|
||||||
// 检查是否存在配置和pairs数组
|
// 检查是否存在配置和pairs数组
|
||||||
const config = singleReviewPoint.config as {
|
const config = singleReviewPoint.config as {
|
||||||
logic?: string;
|
logic?: string;
|
||||||
pairs?: Array<{
|
pairs?: Array<{
|
||||||
sourceField: Record<string, { page: number; value: string }>;
|
sourceField: Record<string, { page: number; value: string; char_positions?: CharPosition[] }>;
|
||||||
targetField: Record<string, { page: number; value: string }>;
|
targetField: Record<string, { page: number; value: string; char_positions?: CharPosition[] }>;
|
||||||
res: boolean;
|
res: boolean;
|
||||||
compareMethod?: string;
|
compareMethod?: string;
|
||||||
}>;
|
}>;
|
||||||
selectedFields?: string[]
|
selectedFields?: string[]
|
||||||
} | undefined;
|
} | undefined;
|
||||||
|
|
||||||
if (!config || !config.pairs || !Array.isArray(config.pairs) || config.pairs.length === 0) {
|
if (!config || !config.pairs || !Array.isArray(config.pairs) || config.pairs.length === 0) {
|
||||||
@@ -865,27 +875,28 @@ export function ReviewPointsList({
|
|||||||
|
|
||||||
// 查找链条关系
|
// 查找链条关系
|
||||||
const findChains = () => {
|
const findChains = () => {
|
||||||
type ChainItem = {
|
type ChainItem = {
|
||||||
field: string;
|
field: string;
|
||||||
data: {
|
data: {
|
||||||
key: string;
|
key: string;
|
||||||
page: number;
|
page: number;
|
||||||
value: string
|
value: string;
|
||||||
};
|
char_positions?: CharPosition[];
|
||||||
|
};
|
||||||
res: boolean;
|
res: boolean;
|
||||||
compareMethod?: string;
|
compareMethod?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const chains: Array<Array<ChainItem>> = [];
|
const chains: Array<Array<ChainItem>> = [];
|
||||||
const visited = new Set<string>();
|
const visited = new Set<string>();
|
||||||
|
|
||||||
// 构建字段映射关系
|
// 构建字段映射关系
|
||||||
const fieldMap = new Map<string, Array<{
|
const fieldMap = new Map<string, Array<{
|
||||||
targetField: string;
|
targetField: string;
|
||||||
data: {
|
data: {
|
||||||
source: { key: string; page: number; value: string };
|
source: { key: string; page: number; value: string; char_positions?: CharPosition[] };
|
||||||
target: { key: string; page: number; value: string };
|
target: { key: string; page: number; value: string; char_positions?: CharPosition[] };
|
||||||
};
|
};
|
||||||
res: boolean;
|
res: boolean;
|
||||||
compareMethod?: string;
|
compareMethod?: string;
|
||||||
}>>();
|
}>>();
|
||||||
@@ -1102,7 +1113,7 @@ export function ReviewPointsList({
|
|||||||
for (const item of chain) {
|
for (const item of chain) {
|
||||||
if (item.data.page && typeof onReviewPointSelect === 'function') {
|
if (item.data.page && typeof onReviewPointSelect === 'function') {
|
||||||
hasPage = true;
|
hasPage = true;
|
||||||
onReviewPointSelect(reviewPoint.id, Number(item.data.page));
|
onReviewPointSelect(reviewPoint.id, Number(item.data.page), item.data.char_positions);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1116,7 +1127,7 @@ export function ReviewPointsList({
|
|||||||
// 遍历chain找到第一个有效的page
|
// 遍历chain找到第一个有效的page
|
||||||
for (const item of chain) {
|
for (const item of chain) {
|
||||||
if (item.data.page && typeof onReviewPointSelect === 'function') {
|
if (item.data.page && typeof onReviewPointSelect === 'function') {
|
||||||
onReviewPointSelect(reviewPoint.id, Number(item.data.page));
|
onReviewPointSelect(reviewPoint.id, Number(item.data.page), item.data.char_positions);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1152,11 +1163,11 @@ export function ReviewPointsList({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (item.data.page) {
|
if (item.data.page) {
|
||||||
// console.log('currentitem-------', reviewPoint);
|
console.log('点击了长链条评查点', item.data.char_positions);
|
||||||
// 假设onReviewPointSelect在作用域内可用
|
// 假设onReviewPointSelect在作用域内可用
|
||||||
const reviewPointId = reviewPoint.id as string;
|
const reviewPointId = reviewPoint.id as string;
|
||||||
if (reviewPointId && typeof onReviewPointSelect === 'function') {
|
if (reviewPointId && typeof onReviewPointSelect === 'function') {
|
||||||
onReviewPointSelect(reviewPointId, Number(item.data.page));
|
onReviewPointSelect(reviewPointId, Number(item.data.page), item.data.char_positions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if(reviewPoint.contentPage && reviewPoint.contentPage[item.field]){
|
else if(reviewPoint.contentPage && reviewPoint.contentPage[item.field]){
|
||||||
@@ -1237,9 +1248,10 @@ export function ReviewPointsList({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (chain[0].data.page) {
|
if (chain[0].data.page) {
|
||||||
|
console.log('点击了短链1左', chain[0].data.char_positions)
|
||||||
const reviewPointId = reviewPoint.id as string;
|
const reviewPointId = reviewPoint.id as string;
|
||||||
if (reviewPointId && typeof onReviewPointSelect === 'function') {
|
if (reviewPointId && typeof onReviewPointSelect === 'function') {
|
||||||
onReviewPointSelect(reviewPointId, chain[0].data.page);
|
onReviewPointSelect(reviewPointId, chain[0].data.page, chain[0].data.char_positions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if(reviewPoint.contentPage && reviewPoint.contentPage[chain[0].field]){
|
else if(reviewPoint.contentPage && reviewPoint.contentPage[chain[0].field]){
|
||||||
@@ -1263,9 +1275,10 @@ export function ReviewPointsList({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (chain[1].data.page) {
|
if (chain[1].data.page) {
|
||||||
|
console.log('点击了短链2右', chain[1].data.char_positions)
|
||||||
const reviewPointId = reviewPoint.id as string;
|
const reviewPointId = reviewPoint.id as string;
|
||||||
if (reviewPointId && typeof onReviewPointSelect === 'function') {
|
if (reviewPointId && typeof onReviewPointSelect === 'function') {
|
||||||
onReviewPointSelect(reviewPointId, chain[1].data.page);
|
onReviewPointSelect(reviewPointId, chain[1].data.page, chain[1].data.char_positions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if(reviewPoint.contentPage && reviewPoint.contentPage[chain[1].field]){
|
else if(reviewPoint.contentPage && reviewPoint.contentPage[chain[1].field]){
|
||||||
@@ -1338,12 +1351,13 @@ export function ReviewPointsList({
|
|||||||
*/
|
*/
|
||||||
const renderOtherRule = (otherRule: Record<string, unknown>, reviewPoint: ReviewPoint) => {
|
const renderOtherRule = (otherRule: Record<string, unknown>, reviewPoint: ReviewPoint) => {
|
||||||
const fieldKey = otherRule.fieldKey as string;
|
const fieldKey = otherRule.fieldKey as string;
|
||||||
const fieldValue = otherRule.fieldValue as {
|
const fieldValue = otherRule.fieldValue as {
|
||||||
type: Record<string, {
|
type: Record<string, {
|
||||||
res: boolean;
|
res: boolean;
|
||||||
page?: number | string;
|
page?: number | string;
|
||||||
value?: string;
|
value?: string;
|
||||||
}>;
|
char_positions?: CharPosition[];
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取res的综合结果
|
// 获取res的综合结果
|
||||||
@@ -1404,7 +1418,8 @@ export function ReviewPointsList({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (mainTypeValue.page && typeof onReviewPointSelect === 'function') {
|
if (mainTypeValue.page && typeof onReviewPointSelect === 'function') {
|
||||||
onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page));
|
console.log("点击了其他评查点", mainTypeValue)
|
||||||
|
onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), mainTypeValue.char_positions);
|
||||||
}else if(reviewPoint.contentPage && reviewPoint.contentPage[fieldKey]){
|
}else if(reviewPoint.contentPage && reviewPoint.contentPage[fieldKey]){
|
||||||
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[fieldKey]));
|
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[fieldKey]));
|
||||||
}else{
|
}else{
|
||||||
@@ -1415,7 +1430,7 @@ export function ReviewPointsList({
|
|||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (mainTypeValue.page && typeof onReviewPointSelect === 'function') {
|
if (mainTypeValue.page && typeof onReviewPointSelect === 'function') {
|
||||||
onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page));
|
onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), mainTypeValue.char_positions);
|
||||||
}else{
|
}else{
|
||||||
toastService.error(`没有找到${fieldKey}对应的索引内容`);
|
toastService.error(`没有找到${fieldKey}对应的索引内容`);
|
||||||
}
|
}
|
||||||
@@ -1478,12 +1493,13 @@ export function ReviewPointsList({
|
|||||||
const renderModelRule = (aiRule: Record<string, unknown>, reviewPoint: ReviewPoint) => {
|
const renderModelRule = (aiRule: Record<string, unknown>, reviewPoint: ReviewPoint) => {
|
||||||
|
|
||||||
// 从aiRule中提取配置信息
|
// 从aiRule中提取配置信息
|
||||||
const config = aiRule.config as {
|
const config = aiRule.config as {
|
||||||
model?: string;
|
model?: string;
|
||||||
fields?: Record<string, {
|
fields?: Record<string, {
|
||||||
page: number | string;
|
page: number | string;
|
||||||
value: string;
|
value: string;
|
||||||
}>;
|
char_positions?: CharPosition[];
|
||||||
|
}>;
|
||||||
message?: string;
|
message?: string;
|
||||||
res?: boolean;
|
res?: boolean;
|
||||||
} | undefined;
|
} | undefined;
|
||||||
@@ -1525,7 +1541,8 @@ export function ReviewPointsList({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (value.page && typeof onReviewPointSelect === 'function') {
|
if (value.page && typeof onReviewPointSelect === 'function') {
|
||||||
onReviewPointSelect(reviewPoint.id, Number(value.page));
|
console.log("点击了大模型的评查点", value.char_positions)
|
||||||
|
onReviewPointSelect(reviewPoint.id, Number(value.page), value.char_positions);
|
||||||
}else if(reviewPoint.contentPage && reviewPoint.contentPage[key]){
|
}else if(reviewPoint.contentPage && reviewPoint.contentPage[key]){
|
||||||
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[key]));
|
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[key]));
|
||||||
}else{
|
}else{
|
||||||
@@ -1537,7 +1554,7 @@ export function ReviewPointsList({
|
|||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (value.page && typeof onReviewPointSelect === 'function') {
|
if (value.page && typeof onReviewPointSelect === 'function') {
|
||||||
onReviewPointSelect(reviewPoint.id, Number(value.page));
|
onReviewPointSelect(reviewPoint.id, Number(value.page), value.char_positions);
|
||||||
}else if(reviewPoint.contentPage && reviewPoint.contentPage[key]){
|
}else if(reviewPoint.contentPage && reviewPoint.contentPage[key]){
|
||||||
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[key]));
|
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[key]));
|
||||||
}else{
|
}else{
|
||||||
@@ -1652,6 +1669,7 @@ export function ReviewPointsList({
|
|||||||
interface RuleFieldValue {
|
interface RuleFieldValue {
|
||||||
page?: number | string;
|
page?: number | string;
|
||||||
value?: string;
|
value?: string;
|
||||||
|
char_positions?: CharPosition[];
|
||||||
type: Record<string, boolean>;
|
type: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1670,7 +1688,7 @@ export function ReviewPointsList({
|
|||||||
// 使用类型断言获取config对象的具体结构
|
// 使用类型断言获取config对象的具体结构
|
||||||
const config = rule.config as {
|
const config = rule.config as {
|
||||||
res: boolean;
|
res: boolean;
|
||||||
fields: Record<string, { page: number; value: string }>;
|
fields: Record<string, { page: number; value: string; char_positions?: CharPosition[] }>;
|
||||||
logic?: string;
|
logic?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1717,7 +1735,7 @@ export function ReviewPointsList({
|
|||||||
// 使用类型断言获取config对象的具体结构
|
// 使用类型断言获取config对象的具体结构
|
||||||
const config = rule.config as {
|
const config = rule.config as {
|
||||||
res: boolean;
|
res: boolean;
|
||||||
field: Record<string, { page: string | number; value: string }>;
|
field: Record<string, { page: string | number; value: string; char_positions?: CharPosition[] }>;
|
||||||
formatType?: string;
|
formatType?: string;
|
||||||
parameters?: string;
|
parameters?: string;
|
||||||
};
|
};
|
||||||
@@ -1751,7 +1769,7 @@ export function ReviewPointsList({
|
|||||||
logic: string;
|
logic: string;
|
||||||
res: boolean;
|
res: boolean;
|
||||||
conditions: Array<{
|
conditions: Array<{
|
||||||
field: Record<string, { page: number | string; value: string }>;
|
field: Record<string, { page: number | string; value: string; char_positions?: CharPosition[] }>;
|
||||||
value: string;
|
value: string;
|
||||||
operator: string;
|
operator: string;
|
||||||
res: boolean;
|
res: boolean;
|
||||||
@@ -1786,7 +1804,7 @@ export function ReviewPointsList({
|
|||||||
// 使用类型断言获取config对象的具体结构
|
// 使用类型断言获取config对象的具体结构
|
||||||
const config = rule.config as {
|
const config = rule.config as {
|
||||||
res: boolean;
|
res: boolean;
|
||||||
field: Record<string, { page: number | string; value: string }>;
|
field: Record<string, { page: number | string; value: string; char_positions?: CharPosition[] }>;
|
||||||
pattern?: string;
|
pattern?: string;
|
||||||
matchType?: string;
|
matchType?: string;
|
||||||
selectedFields?: string[];
|
selectedFields?: string[];
|
||||||
@@ -1821,10 +1839,11 @@ export function ReviewPointsList({
|
|||||||
res: boolean;
|
res: boolean;
|
||||||
page?: number | string;
|
page?: number | string;
|
||||||
value?: string;
|
value?: string;
|
||||||
|
char_positions?: CharPosition[];
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
// 使用对象存储相同fieldKey的项,便于快速查找和合并
|
// 使用对象存储相同fieldKey的项,便于快速查找和合并
|
||||||
const fieldKeyMap: Record<string, {
|
const fieldKeyMap: Record<string, {
|
||||||
fieldKey: string;
|
fieldKey: string;
|
||||||
@@ -1833,6 +1852,7 @@ export function ReviewPointsList({
|
|||||||
res: boolean;
|
res: boolean;
|
||||||
page?: number | string;
|
page?: number | string;
|
||||||
value?: string;
|
value?: string;
|
||||||
|
char_positions?: CharPosition[];
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
}> = {};
|
}> = {};
|
||||||
@@ -1843,11 +1863,12 @@ export function ReviewPointsList({
|
|||||||
const fieldValue = item.fieldValue;
|
const fieldValue = item.fieldValue;
|
||||||
const typeKey = Object.keys(fieldValue.type)[0]; // 获取类型名称(exists/logic/regex/format)
|
const typeKey = Object.keys(fieldValue.type)[0]; // 获取类型名称(exists/logic/regex/format)
|
||||||
const typeValue = fieldValue.type[typeKey]; // 获取类型值(true/false)
|
const typeValue = fieldValue.type[typeKey]; // 获取类型值(true/false)
|
||||||
|
|
||||||
// 提取页码和值
|
// 提取页码、值和字符位置
|
||||||
const page = fieldValue.page;
|
const page = fieldValue.page;
|
||||||
const value = fieldValue.value;
|
const value = fieldValue.value;
|
||||||
|
const char_positions = fieldValue.char_positions;
|
||||||
|
|
||||||
// 如果是第一次遇到这个fieldKey,创建新条目
|
// 如果是第一次遇到这个fieldKey,创建新条目
|
||||||
if (!fieldKeyMap[fieldKey]) {
|
if (!fieldKeyMap[fieldKey]) {
|
||||||
// 创建新的结构
|
// 创建新的结构
|
||||||
@@ -1858,12 +1879,13 @@ export function ReviewPointsList({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将类型信息添加到type对象中,允许一个字段有多种规则类型的结果
|
// 将类型信息添加到type对象中,允许一个字段有多种规则类型的结果
|
||||||
fieldKeyMap[fieldKey].fieldValue.type[typeKey] = {
|
fieldKeyMap[fieldKey].fieldValue.type[typeKey] = {
|
||||||
res: typeValue,
|
res: typeValue,
|
||||||
page,
|
page,
|
||||||
value
|
value,
|
||||||
|
char_positions
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2270,7 +2292,7 @@ export function ReviewPointsList({
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
style={{ userSelect: 'text' }}
|
style={{ userSelect: 'text' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// console.log('reviewPoint', reviewPoint);
|
console.log('reviewPoint', reviewPoint);
|
||||||
handleReviewPointClick(reviewPoint.id);
|
handleReviewPointClick(reviewPoint.id);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
* 提供三个选项卡:评查结果、AI智能分析、文件信息
|
* 提供三个选项卡:评查结果、AI智能分析、文件信息
|
||||||
*/
|
*/
|
||||||
import { ReactNode, useState, useRef } from 'react';
|
import { ReactNode, useState, useRef } from 'react';
|
||||||
import { useNavigate, useRevalidator } from 'react-router-dom';
|
// import { useNavigate, useRevalidator } from 'react-router-dom';
|
||||||
|
import { useNavigate, useRevalidator } from '@remix-run/react';
|
||||||
import { loadingBarService } from '~/components/ui/LoadingBar';
|
import { loadingBarService } from '~/components/ui/LoadingBar';
|
||||||
import { Modal } from '~/components/ui/Modal';
|
import { Modal } from '~/components/ui/Modal';
|
||||||
import { UploadArea, type UploadAreaRef } from '~/components/ui/UploadArea';
|
import { UploadArea, type UploadAreaRef } from '~/components/ui/UploadArea';
|
||||||
|
|||||||
@@ -0,0 +1,472 @@
|
|||||||
|
/**
|
||||||
|
* PDF预览组件
|
||||||
|
* 从 FilePreview.tsx 中抽取的 PDF 专用渲染组件
|
||||||
|
*/
|
||||||
|
import { useState, useEffect, useRef, ChangeEvent } from 'react';
|
||||||
|
import { Document, Page, pdfjs } from 'react-pdf';
|
||||||
|
import { toastService } from '~/components/ui/Toast';
|
||||||
|
|
||||||
|
// 导入react-pdf的CSS样式(文本层和注释层必需)
|
||||||
|
import 'react-pdf/dist/esm/Page/TextLayer.css';
|
||||||
|
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
|
||||||
|
|
||||||
|
// 设置worker路径
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义样式
|
||||||
|
* 这些样式解决了PDF页面在放大时互相重叠的问题
|
||||||
|
*/
|
||||||
|
const styles = {
|
||||||
|
pdfContainer: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column' as const,
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative' as const,
|
||||||
|
},
|
||||||
|
pageContainer: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column' as const,
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative' as const,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PdfPreviewProps {
|
||||||
|
filePath: string; // PDF 文件路径
|
||||||
|
targetPage?: number; // 目标页码
|
||||||
|
charPositions?: Array<{ box: number[][], char: string, score: number }>; // 字符位置信息(用于高亮显示)
|
||||||
|
isStructuredView?: boolean; // 是否结构化视图
|
||||||
|
activeReviewPointResultId?: string | null; // 激活的评查点结果ID
|
||||||
|
pageOffset?: number; // 页码偏移量(用于调整 OCR 结果的页码)
|
||||||
|
onNumPagesChange?: (numPages: number) => void; // 页数变化回调
|
||||||
|
onZoomChange?: (zoomLevel: number) => void; // 缩放变化回调
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PdfPreview({
|
||||||
|
filePath,
|
||||||
|
targetPage,
|
||||||
|
charPositions,
|
||||||
|
isStructuredView = false,
|
||||||
|
activeReviewPointResultId,
|
||||||
|
pageOffset = 0,
|
||||||
|
onNumPagesChange,
|
||||||
|
onZoomChange
|
||||||
|
}: PdfPreviewProps) {
|
||||||
|
// 调试日志
|
||||||
|
console.log('[PdfPreview] 组件渲染', {
|
||||||
|
filePath,
|
||||||
|
targetPage,
|
||||||
|
charPositions,
|
||||||
|
isStructuredView,
|
||||||
|
activeReviewPointResultId,
|
||||||
|
pageOffset
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ 状态管理 ============
|
||||||
|
const [numPages, setNumPages] = useState<number | null>(null);
|
||||||
|
const [zoomLevel, setZoomLevel] = useState(100);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [pageInputValue, setPageInputValue] = useState<string>('');
|
||||||
|
|
||||||
|
// 坐标校准参数
|
||||||
|
const [coordinateScale, setCoordinateScale] = useState(0.83); // 坐标缩放系数(默认0.83)
|
||||||
|
const [pdfOriginalWidth, setPdfOriginalWidth] = useState<number>(0); // PDF原始尺寸
|
||||||
|
const [isScaleAutoCalculated, setIsScaleAutoCalculated] = useState(false); // 是否已自动计算缩放
|
||||||
|
|
||||||
|
// 引用
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const prevTargetPageRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
|
// ============ 副作用 - 页数变化通知 ============
|
||||||
|
useEffect(() => {
|
||||||
|
if (numPages && onNumPagesChange) {
|
||||||
|
onNumPagesChange(numPages);
|
||||||
|
}
|
||||||
|
}, [numPages, onNumPagesChange]);
|
||||||
|
|
||||||
|
// ============ 副作用 - 缩放变化通知 ============
|
||||||
|
useEffect(() => {
|
||||||
|
if (onZoomChange) {
|
||||||
|
onZoomChange(zoomLevel);
|
||||||
|
}
|
||||||
|
}, [zoomLevel, onZoomChange]);
|
||||||
|
|
||||||
|
// ============ 副作用 - 页面跳转 ============
|
||||||
|
useEffect(() => {
|
||||||
|
if (targetPage && numPages && targetPage <= numPages) {
|
||||||
|
prevTargetPageRef.current = targetPage;
|
||||||
|
const newTargetPage = targetPage + pageOffset;
|
||||||
|
|
||||||
|
const pageElementId = `page-${newTargetPage}${isStructuredView ? '-structured' : ''}`;
|
||||||
|
const pageElement = document.getElementById(pageElementId);
|
||||||
|
|
||||||
|
if (pageElement) {
|
||||||
|
// 直接跳转,不使用滚动动画
|
||||||
|
pageElement.scrollIntoView({ behavior: 'auto', block: 'start' });
|
||||||
|
} else {
|
||||||
|
console.warn(`未找到页面元素: ${pageElementId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [targetPage, numPages, pageOffset, activeReviewPointResultId, isStructuredView]);
|
||||||
|
|
||||||
|
// ============ 副作用 - 重置坐标缩放计算标志 ============
|
||||||
|
useEffect(() => {
|
||||||
|
// 只在文件路径变化时重置自动计算标志
|
||||||
|
// zoom变化时不应该重新计算coordinateScale,因为它是基于PDF原始尺寸的固定值
|
||||||
|
setIsScaleAutoCalculated(false);
|
||||||
|
}, [filePath]);
|
||||||
|
|
||||||
|
// ============ PDF 加载事件处理 ============
|
||||||
|
const onDocumentLoadSuccess = ({ numPages }: { numPages: number }) => {
|
||||||
|
setNumPages(numPages);
|
||||||
|
console.log("PDF加载成功,页数:", numPages);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDocumentLoadError = (error: Error) => {
|
||||||
|
console.error("PDF加载错误:", error);
|
||||||
|
setLoadError("PDF文档加载失败:" + (error.message || "未知错误"));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ 缩放控制 ============
|
||||||
|
const handleZoomIn = () => {
|
||||||
|
if (zoomLevel < 200) {
|
||||||
|
setZoomLevel(prevZoom => prevZoom + 10);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomOut = () => {
|
||||||
|
if (zoomLevel > 50) {
|
||||||
|
setZoomLevel(prevZoom => prevZoom - 10);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ 页码跳转控制 ============
|
||||||
|
const handlePageInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value.replace(/\D/g, '');
|
||||||
|
setPageInputValue(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageJump = () => {
|
||||||
|
if (!pageInputValue || !numPages) return;
|
||||||
|
|
||||||
|
const targetPageNum = parseInt(pageInputValue, 10);
|
||||||
|
|
||||||
|
if (targetPageNum > 0 && targetPageNum <= numPages) {
|
||||||
|
const pageElement = document.getElementById(`page-${targetPageNum}`);
|
||||||
|
if (pageElement) {
|
||||||
|
// 直接跳转,不使用滚动动画
|
||||||
|
pageElement.scrollIntoView({ behavior: 'auto', block: 'start' });
|
||||||
|
setPageInputValue('');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toastService.warning(`请输入有效页码 (1-${numPages})`);
|
||||||
|
setPageInputValue('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handlePageJump();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ 滚动到顶部 ============
|
||||||
|
const handleScrollToTop = () => {
|
||||||
|
if (contentRef.current) {
|
||||||
|
// 直接跳转到顶部,不使用滚动动画
|
||||||
|
contentRef.current.scrollTo({ top: 0, behavior: 'auto' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ 计算页面间距 ============
|
||||||
|
const calculatePageMargin = (zoomFactor: number) => {
|
||||||
|
// 固定间距,不随缩放变化,避免页面偏移
|
||||||
|
return 32; // 相当于 Tailwind 的 mb-8
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ 页面加载成功处理(用于自动计算坐标缩放) ============
|
||||||
|
const onPageLoadSuccess = (page: any) => {
|
||||||
|
// 只在第一页加载时计算一次坐标缩放系数
|
||||||
|
if (page.pageNumber === 1 && !isScaleAutoCalculated) {
|
||||||
|
setTimeout(() => {
|
||||||
|
// 获取PDF原始宽度(单位:点)
|
||||||
|
const pdfOriginalWidthPt = page.view?.[2] || page.originalWidth || page.width;
|
||||||
|
|
||||||
|
// 获取实际渲染的canvas元素
|
||||||
|
const canvas = document.querySelector('.react-pdf__Page__canvas') as HTMLCanvasElement;
|
||||||
|
|
||||||
|
if (canvas && pdfOriginalWidthPt) {
|
||||||
|
// 获取canvas在屏幕上的显示宽度
|
||||||
|
const canvasDisplayWidth = canvas.offsetWidth;
|
||||||
|
|
||||||
|
// 获取当前的zoom因子
|
||||||
|
const currentScale = zoomLevel / 100;
|
||||||
|
|
||||||
|
// 计算基于100% zoom的坐标缩放系数
|
||||||
|
// 需要除以当前的zoom因子,得到100%时的真实比例
|
||||||
|
const autoScale = (canvasDisplayWidth / currentScale) / pdfOriginalWidthPt;
|
||||||
|
|
||||||
|
console.log('自动计算坐标缩放:', {
|
||||||
|
pdfOriginalWidth: pdfOriginalWidthPt,
|
||||||
|
canvasDisplayWidth,
|
||||||
|
currentZoom: zoomLevel,
|
||||||
|
currentScale,
|
||||||
|
autoScale
|
||||||
|
});
|
||||||
|
|
||||||
|
setPdfOriginalWidth(pdfOriginalWidthPt);
|
||||||
|
setCoordinateScale(autoScale);
|
||||||
|
setIsScaleAutoCalculated(true);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ 处理字符位置数据,转换为高亮矩形 ============
|
||||||
|
const processCharPositionsToHighlights = () => {
|
||||||
|
if (!charPositions || charPositions.length === 0 || !targetPage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = zoomLevel / 100;
|
||||||
|
|
||||||
|
// 计算所有字符的边界框,形成一个连贯的高亮区域
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
|
||||||
|
// 收集所有字符文本
|
||||||
|
const allChars = charPositions.map(cp => cp.char).join('');
|
||||||
|
|
||||||
|
// 遍历所有字符,找出整体的边界
|
||||||
|
charPositions.forEach(charPos => {
|
||||||
|
const box = charPos.box;
|
||||||
|
|
||||||
|
// box是一个4个点的数组: [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
|
||||||
|
// 从所有点中找出最小和最大的坐标
|
||||||
|
box.forEach(point => {
|
||||||
|
const x = point[0];
|
||||||
|
const y = point[1];
|
||||||
|
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算连贯的高亮矩形,应用坐标缩放和zoom缩放
|
||||||
|
const x = minX * coordinateScale * scale;
|
||||||
|
const y = minY * coordinateScale * scale;
|
||||||
|
const width = (maxX - minX) * coordinateScale * scale;
|
||||||
|
const height = (maxY - minY) * coordinateScale * scale;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
text: allChars
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ 渲染所有PDF页面 ============
|
||||||
|
const renderAllPages = () => {
|
||||||
|
if (!numPages) return null;
|
||||||
|
|
||||||
|
const pages = [];
|
||||||
|
const zoomFactor = zoomLevel / 100;
|
||||||
|
const highlight = processCharPositionsToHighlights();
|
||||||
|
|
||||||
|
for (let i = 1; i <= numPages; i++) {
|
||||||
|
const pageContainerStyle = {
|
||||||
|
...styles.pageContainer,
|
||||||
|
marginBottom: `${calculatePageMargin(zoomFactor)}px`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageId = isStructuredView ? `page-${i}-structured` : `page-${i}`;
|
||||||
|
|
||||||
|
// 计算调整后的目标页码(考虑pageOffset)
|
||||||
|
const adjustedTargetPage = targetPage ? targetPage + pageOffset : undefined;
|
||||||
|
|
||||||
|
// 判断当前页是否应该显示高亮
|
||||||
|
const shouldShowHighlight = adjustedTargetPage === i && highlight !== null;
|
||||||
|
|
||||||
|
pages.push(
|
||||||
|
<div key={i} id={pageId} style={pageContainerStyle}>
|
||||||
|
<div className="text-center text-gray-500 text-sm mb-2">第 {i} 页</div>
|
||||||
|
<div
|
||||||
|
className="page-wrapper"
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'inline-block',
|
||||||
|
margin: '0 auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Page
|
||||||
|
pageNumber={i}
|
||||||
|
scale={zoomLevel / 100}
|
||||||
|
devicePixelRatio={window.devicePixelRatio || 1}
|
||||||
|
renderTextLayer={false}
|
||||||
|
renderAnnotationLayer={false}
|
||||||
|
className="border border-gray-300 shadow-md"
|
||||||
|
onLoadSuccess={onPageLoadSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 坐标高亮层 - 渲染连贯的高亮区域 */}
|
||||||
|
{shouldShowHighlight && highlight && (
|
||||||
|
<svg
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 12
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x={highlight.x}
|
||||||
|
y={highlight.y}
|
||||||
|
width={highlight.width}
|
||||||
|
height={highlight.height}
|
||||||
|
fill="#00AA00"
|
||||||
|
fillOpacity="0.1"
|
||||||
|
stroke="#00684a"
|
||||||
|
strokeWidth="1"
|
||||||
|
rx="2"
|
||||||
|
>
|
||||||
|
<title>{`高亮文本: ${highlight.text}`}</title>
|
||||||
|
</rect>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ 主渲染 ============
|
||||||
|
return (
|
||||||
|
<div className="pdf-preview-component">
|
||||||
|
{/* 工具栏 */}
|
||||||
|
<div className="file-preview-header px-2 text-xs sm:text-xs md:text-sm max-w-full flex items-center justify-between min-w-0">
|
||||||
|
<div className="flex items-center min-w-0 flex-shrink-0">
|
||||||
|
<i className={`${isStructuredView ? 'ri-file-list-line' : 'ri-file-text-line'} text-primary mr-2 flex-shrink-0`}></i>
|
||||||
|
<span className="font-medium text-primary truncate max-w-[120px]" title={isStructuredView ? '模板预览' : '文件预览'}>
|
||||||
|
{isStructuredView ? '模板预览' : '文件预览'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<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 hover:bg-gray-50 hover:border-primary hover:text-primary transition-colors duration-200 flex-shrink-0 outline-none"
|
||||||
|
onClick={handleScrollToTop}
|
||||||
|
title="返回顶部"
|
||||||
|
>
|
||||||
|
<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">返回顶部</span>
|
||||||
|
</button>
|
||||||
|
<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 flex-shrink-0 outline-none disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-white disabled:hover:border-gray-300 disabled:hover:text-gray-700"
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
title="放大"
|
||||||
|
disabled={zoomLevel >= 200}
|
||||||
|
>
|
||||||
|
<i className="ri-zoom-in-line text-sm"></i>
|
||||||
|
</button>
|
||||||
|
<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 flex-shrink-0 outline-none disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-white disabled:hover:border-gray-300 disabled:hover:text-gray-700"
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
title="缩小"
|
||||||
|
disabled={zoomLevel <= 50}
|
||||||
|
>
|
||||||
|
<i className="ri-zoom-out-line text-sm"></i>
|
||||||
|
</button>
|
||||||
|
{/* 页码跳转控件 */}
|
||||||
|
<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"
|
||||||
|
placeholder="页码"
|
||||||
|
value={pageInputValue}
|
||||||
|
onChange={handlePageInputChange}
|
||||||
|
onKeyDown={handlePageInputKeyDown}
|
||||||
|
/>
|
||||||
|
<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}
|
||||||
|
title="跳转到页面"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
/ {numPages}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500 hidden sm:hidden md:hidden lg:hidden xl:inline whitespace-nowrap flex-shrink-0">
|
||||||
|
比例:{zoomLevel}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PDF内容区域 */}
|
||||||
|
<div
|
||||||
|
className="file-preview-content"
|
||||||
|
ref={contentRef}
|
||||||
|
style={{
|
||||||
|
maxHeight: 'calc(100vh - 150px)',
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="pdf-interactive-container"
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
display: 'block',
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loadError ? (
|
||||||
|
<div className="text-red-500 p-4">
|
||||||
|
<p>{loadError}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.pdfContainer,
|
||||||
|
width: '100%',
|
||||||
|
overflow: 'visible'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Document
|
||||||
|
file={`/api/pdf-proxy?path=${encodeURIComponent(filePath)}`}
|
||||||
|
onLoadSuccess={onDocumentLoadSuccess}
|
||||||
|
onLoadError={onDocumentLoadError}
|
||||||
|
className="w-full"
|
||||||
|
error={<div className="text-red-500">PDF文档加载失败,请检查链接或网络连接。</div>}
|
||||||
|
noData={<div>无数据</div>}
|
||||||
|
loading={<div className="text-center py-10">PDF加载中...</div>}
|
||||||
|
>
|
||||||
|
{renderAllPages()}
|
||||||
|
</Document>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+200
-91
@@ -13,6 +13,7 @@ import { useState, useRef, useEffect } from "react";
|
|||||||
import { DiffEditor } from "@monaco-editor/react";
|
import { DiffEditor } from "@monaco-editor/react";
|
||||||
import type { editor } from "monaco-editor";
|
import type { editor } from "monaco-editor";
|
||||||
import { pdfjs } from 'react-pdf';
|
import { pdfjs } from 'react-pdf';
|
||||||
|
import mammoth from 'mammoth';
|
||||||
import { toastService } from '~/components/ui/Toast';
|
import { toastService } from '~/components/ui/Toast';
|
||||||
|
|
||||||
// 设置 PDF.js worker(与 pdf-demo.tsx 相同)
|
// 设置 PDF.js worker(与 pdf-demo.tsx 相同)
|
||||||
@@ -25,14 +26,26 @@ export const meta: MetaFunction = () => {
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 文档类型枚举
|
||||||
|
type DocumentType = 'pdf' | 'docx' | 'unknown';
|
||||||
|
|
||||||
// PDF 类型枚举
|
// PDF 类型枚举
|
||||||
type PdfType = 'text' | 'scanned' | 'unknown';
|
type PdfType = 'text' | 'scanned' | 'unknown';
|
||||||
|
|
||||||
// PDF 信息接口
|
// PDF 信息接口(内部使用)
|
||||||
interface PdfInfo {
|
interface PdfInfo {
|
||||||
type: PdfType;
|
type: PdfType;
|
||||||
numPages: number;
|
numPages: number;
|
||||||
textLength: number;
|
textLength: number;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文档信息接口
|
||||||
|
interface DocumentInfo {
|
||||||
|
fileType: DocumentType;
|
||||||
|
pdfType?: PdfType; // 只有 PDF 才有
|
||||||
|
numPages?: number; // PDF 页数
|
||||||
|
textLength: number;
|
||||||
confidence: number; // 文本提取置信度 (0-1)
|
confidence: number; // 文本提取置信度 (0-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,15 +133,28 @@ export default function MonacoDemoPage() {
|
|||||||
const [diffCount, setDiffCount] = useState<number>(0);
|
const [diffCount, setDiffCount] = useState<number>(0);
|
||||||
const [currentDiff, setCurrentDiff] = useState<number>(0);
|
const [currentDiff, setCurrentDiff] = useState<number>(0);
|
||||||
|
|
||||||
// PDF相关状态
|
// 文档相关状态
|
||||||
const [pdf1Url, setPdf1Url] = useState<string>('');
|
// 默认使用的测试文档路径(相对路径)
|
||||||
const [pdf2Url, setPdf2Url] = useState<string>('');
|
// 注意:文件名中使用的是中文全角括号()而不是英文半角括号()
|
||||||
const [pdf1Info, setPdf1Info] = useState<PdfInfo | null>(null);
|
const DEFAULT_DOC1_URL = '/testWork/(最终版)智慧法务平台建设采购项目合同(1).docx';
|
||||||
const [pdf2Info, setPdf2Info] = useState<PdfInfo | null>(null);
|
const DEFAULT_DOC2_URL = '/testWork/(最终版)智慧法务平台建设采购项目合同(2).docx';
|
||||||
const [isLoadingPdf1, setIsLoadingPdf1] = useState(false);
|
|
||||||
const [isLoadingPdf2, setIsLoadingPdf2] = useState(false);
|
const [doc1Url, setDoc1Url] = useState<string>('');
|
||||||
|
const [doc2Url, setDoc2Url] = useState<string>('');
|
||||||
|
const [doc1Info, setDoc1Info] = useState<DocumentInfo | null>(null);
|
||||||
|
const [doc2Info, setDoc2Info] = useState<DocumentInfo | null>(null);
|
||||||
|
const [isLoadingDoc1, setIsLoadingDoc1] = useState(false);
|
||||||
|
const [isLoadingDoc2, setIsLoadingDoc2] = useState(false);
|
||||||
const [useExample, setUseExample] = useState(true);
|
const [useExample, setUseExample] = useState(true);
|
||||||
|
|
||||||
|
// 检测文件类型(根据文件路径)
|
||||||
|
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类型检测函数
|
// PDF类型检测函数
|
||||||
const detectPdfType = async (pdfUrl: string): Promise<PdfInfo> => {
|
const detectPdfType = async (pdfUrl: string): Promise<PdfInfo> => {
|
||||||
const loadingTask = pdfjs.getDocument(pdfUrl);
|
const loadingTask = pdfjs.getDocument(pdfUrl);
|
||||||
@@ -189,32 +215,82 @@ export default function MonacoDemoPage() {
|
|||||||
return fullText;
|
return fullText;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 加载PDF并提取文本
|
// Word文档文本提取函数
|
||||||
const loadPdfAndExtractText = async (pdfUrl: string, setPdfInfo: (info: PdfInfo | null) => void, setLoading: (loading: boolean) => void, setTextContent: (text: string) => void) => {
|
const extractTextFromWord = async (docUrl: string): Promise<string> => {
|
||||||
|
// 通过 fetch 获取文件
|
||||||
|
const response = await fetch(docUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`无法加载文档: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 ArrayBuffer
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
|
||||||
|
// 使用 mammoth 提取纯文本
|
||||||
|
const textResult = await mammoth.extractRawText({ arrayBuffer });
|
||||||
|
|
||||||
|
return textResult.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载文档并提取文本(支持 PDF 和 Word)
|
||||||
|
const loadDocumentAndExtractText = async (
|
||||||
|
docUrl: string,
|
||||||
|
filePath: string,
|
||||||
|
setDocInfo: (info: DocumentInfo | null) => void,
|
||||||
|
setLoading: (loading: boolean) => void,
|
||||||
|
setTextContent: (text: string) => void
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// 1. 检测PDF类型
|
// 1. 检测文件类型
|
||||||
const pdfInfo = await detectPdfType(pdfUrl);
|
const fileType = detectFileType(filePath);
|
||||||
setPdfInfo(pdfInfo);
|
|
||||||
|
|
||||||
// 2. 提取文本
|
if (fileType === 'pdf') {
|
||||||
if (pdfInfo.type === 'text') {
|
// PDF 处理
|
||||||
const text = await extractTextFromPdf(pdfUrl);
|
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);
|
setTextContent(text);
|
||||||
toastService.success(`PDF加载成功!共 ${pdfInfo.numPages} 页,提取了 ${pdfInfo.textLength} 个字符`);
|
|
||||||
} else if (pdfInfo.type === 'scanned') {
|
if (pdfInfo.type === 'text') {
|
||||||
toastService.warning('检测到扫描版PDF,文本提取质量可能较低');
|
toastService.success(`PDF加载成功!共 ${pdfInfo.numPages} 页,提取了 ${pdfInfo.textLength} 个字符`);
|
||||||
const text = await extractTextFromPdf(pdfUrl);
|
} else if (pdfInfo.type === 'scanned') {
|
||||||
|
toastService.warning('检测到扫描版PDF,文本提取质量可能较低');
|
||||||
|
} else {
|
||||||
|
toastService.error('无法识别PDF类型,可能是图片PDF');
|
||||||
|
}
|
||||||
|
} else if (fileType === 'docx') {
|
||||||
|
// Word 处理
|
||||||
|
const text = await extractTextFromWord(docUrl);
|
||||||
|
|
||||||
|
const docInfo: DocumentInfo = {
|
||||||
|
fileType: 'docx',
|
||||||
|
textLength: text.length,
|
||||||
|
confidence: 1.0 // Word 文档文本提取置信度为 100%
|
||||||
|
};
|
||||||
|
|
||||||
|
setDocInfo(docInfo);
|
||||||
setTextContent(text);
|
setTextContent(text);
|
||||||
|
|
||||||
|
toastService.success(`Word文档加载成功!提取了 ${text.length} 个字符`);
|
||||||
} else {
|
} else {
|
||||||
toastService.error('无法识别PDF类型,可能是图片PDF');
|
toastService.error('不支持的文件类型');
|
||||||
setTextContent('');
|
setTextContent('');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('PDF加载失败:', error);
|
console.error('文档加载失败:', error);
|
||||||
toastService.error('PDF加载失败,请检查文件路径');
|
toastService.error(`文档加载失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||||
setPdfInfo(null);
|
setDocInfo(null);
|
||||||
setTextContent('');
|
setTextContent('');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -281,8 +357,8 @@ export default function MonacoDemoPage() {
|
|||||||
setModifiedText(CONTRACT_B);
|
setModifiedText(CONTRACT_B);
|
||||||
setCurrentDiff(0);
|
setCurrentDiff(0);
|
||||||
setUseExample(true);
|
setUseExample(true);
|
||||||
setPdf1Info(null);
|
setDoc1Info(null);
|
||||||
setPdf2Info(null);
|
setDoc2Info(null);
|
||||||
|
|
||||||
// 重新计算差异数量
|
// 重新计算差异数量
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -295,39 +371,81 @@ export default function MonacoDemoPage() {
|
|||||||
}, 100);
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 从URL参数加载PDF
|
// 从URL参数加载文档(支持 PDF 和 Word)
|
||||||
const loadPdfsFromUrl = () => {
|
const loadDocumentsFromUrl = () => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
const pdf1Path = searchParams.get('pdf1');
|
const doc1Param = searchParams.get('doc1') || searchParams.get('pdf1'); // 兼容旧参数名
|
||||||
const pdf2Path = searchParams.get('pdf2');
|
const doc2Param = searchParams.get('doc2') || searchParams.get('pdf2'); // 兼容旧参数名
|
||||||
|
|
||||||
if (pdf1Path || pdf2Path) {
|
// 只有在传参时才加载文档,否则使用默认的 mock 数据(示例合同)
|
||||||
|
if (doc1Param || doc2Param) {
|
||||||
setUseExample(false);
|
setUseExample(false);
|
||||||
|
|
||||||
if (pdf1Path) {
|
// 文档1
|
||||||
const fullUrl = `/api/pdf-proxy?path=${encodeURIComponent(pdf1Path)}`;
|
if (doc1Param) {
|
||||||
setPdf1Url(fullUrl);
|
const doc1Url = doc1Param.startsWith('/') ? doc1Param : '/' + doc1Param; // 相对路径,确保以 / 开头
|
||||||
loadPdfAndExtractText(fullUrl, setPdf1Info, setIsLoadingPdf1, setOriginalText);
|
setDoc1Url(doc1Url);
|
||||||
|
loadDocumentAndExtractText(doc1Url, doc1Param, setDoc1Info, setIsLoadingDoc1, setOriginalText);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pdf2Path) {
|
// 文档2
|
||||||
const fullUrl = `/api/pdf-proxy?path=${encodeURIComponent(pdf2Path)}`;
|
if (doc2Param) {
|
||||||
setPdf2Url(fullUrl);
|
const doc2Url = doc2Param.startsWith('/') ? doc2Param : '/' + doc2Param; // 相对路径,确保以 / 开头
|
||||||
loadPdfAndExtractText(fullUrl, setPdf2Info, setIsLoadingPdf2, setModifiedText);
|
setDoc2Url(doc2Url);
|
||||||
|
loadDocumentAndExtractText(doc2Url, doc2Param, setDoc2Info, setIsLoadingDoc2, setModifiedText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 如果没有传参,则保持 useExample=true,显示默认的 CONTRACT_A 和 CONTRACT_B
|
||||||
};
|
};
|
||||||
|
|
||||||
// 组件挂载时读取URL参数
|
// 组件挂载时读取URL参数
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPdfsFromUrl();
|
loadDocumentsFromUrl();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 监听文本变化,重新计算差异数量
|
||||||
|
useEffect(() => {
|
||||||
|
// 延迟一点确保编辑器已经渲染完成
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (diffEditorRef.current) {
|
||||||
|
const lineChanges = diffEditorRef.current.getLineChanges();
|
||||||
|
if (lineChanges) {
|
||||||
|
setDiffCount(lineChanges.length);
|
||||||
|
console.log(`✅ 重新计算差异: 发现 ${lineChanges.length} 处差异`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [originalText, modifiedText]); // 当文本内容变化时重新计算
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="monaco-demo-page" style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
<div className="monaco-demo-page" style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{/* 字符级差异高亮样式 - 加深高亮颜色 */}
|
||||||
|
<style>{`
|
||||||
|
/* 修改后的文本(绿色背景)- 字符级差异 */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
{/* 页面头部 */}
|
{/* 页面头部 */}
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '16px 24px',
|
padding: '16px 24px',
|
||||||
@@ -429,31 +547,11 @@ export default function MonacoDemoPage() {
|
|||||||
<i className="ri-refresh-line"></i>
|
<i className="ri-refresh-line"></i>
|
||||||
重置示例
|
重置示例
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 未来扩展:上传按钮 */}
|
|
||||||
<button
|
|
||||||
disabled
|
|
||||||
style={{
|
|
||||||
padding: '6px 12px',
|
|
||||||
backgroundColor: '#e0e0e0',
|
|
||||||
border: '1px solid #d0d0d0',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'not-allowed',
|
|
||||||
fontSize: '14px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '6px',
|
|
||||||
opacity: 0.5
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i className="ri-upload-2-line"></i>
|
|
||||||
上传文件(待开发)
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PDF加载信息 */}
|
{/* 文档加载信息 */}
|
||||||
{!useExample && (pdf1Info || pdf2Info || isLoadingPdf1 || isLoadingPdf2) && (
|
{!useExample && (doc1Info || doc2Info || isLoadingDoc1 || isLoadingDoc2) && 2==1 && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '12px 24px',
|
padding: '12px 24px',
|
||||||
backgroundColor: '#fff3cd',
|
backgroundColor: '#fff3cd',
|
||||||
@@ -462,48 +560,58 @@ export default function MonacoDemoPage() {
|
|||||||
color: '#856404'
|
color: '#856404'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '24px' }}>
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '24px' }}>
|
||||||
<i className="ri-file-pdf-line" style={{ fontSize: '18px', marginTop: '2px' }}></i>
|
<i className="ri-file-text-line" style={{ fontSize: '18px', marginTop: '2px' }}></i>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<strong>PDF文档信息:</strong>
|
<strong>文档信息:</strong>
|
||||||
<div style={{ display: 'flex', gap: '24px', marginTop: '8px' }}>
|
<div style={{ display: 'flex', gap: '24px', marginTop: '8px' }}>
|
||||||
{/* PDF 1 信息 */}
|
{/* 文档 1 信息 */}
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>📄 文档1(左侧/原始)</div>
|
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>📄 文档1(左侧/原始)</div>
|
||||||
{isLoadingPdf1 ? (
|
{isLoadingDoc1 ? (
|
||||||
<div style={{ color: '#666' }}>⏳ 加载中...</div>
|
<div style={{ color: '#666' }}>⏳ 加载中...</div>
|
||||||
) : pdf1Info ? (
|
) : doc1Info ? (
|
||||||
<div>
|
<div>
|
||||||
<div>类型: <span style={{
|
<div>类型: <span style={{
|
||||||
color: pdf1Info.type === 'text' ? '#28a745' : pdf1Info.type === 'scanned' ? '#ffc107' : '#dc3545',
|
color: doc1Info.fileType === 'pdf' ? '#007bff' : doc1Info.fileType === 'docx' ? '#28a745' : '#dc3545',
|
||||||
fontWeight: 'bold'
|
fontWeight: 'bold'
|
||||||
}}>
|
}}>
|
||||||
{pdf1Info.type === 'text' ? '✅ 文本PDF' : pdf1Info.type === 'scanned' ? '⚠️ 扫描PDF' : '❌ 未知类型'}
|
{doc1Info.fileType === 'pdf' ? '📕 PDF文档' : doc1Info.fileType === 'docx' ? '📘 Word文档' : '❌ 未知类型'}
|
||||||
</span></div>
|
</span></div>
|
||||||
<div>页数: {pdf1Info.numPages} 页</div>
|
{doc1Info.fileType === 'pdf' && doc1Info.numPages && (
|
||||||
<div>字符数: {pdf1Info.textLength} 个</div>
|
<div>页数: {doc1Info.numPages} 页</div>
|
||||||
<div>置信度: {(pdf1Info.confidence * 100).toFixed(0)}%</div>
|
)}
|
||||||
|
{doc1Info.fileType === 'pdf' && doc1Info.pdfType && (
|
||||||
|
<div>PDF类型: {doc1Info.pdfType === 'text' ? '✅ 文本' : doc1Info.pdfType === 'scanned' ? '⚠️ 扫描' : '❌ 未知'}</div>
|
||||||
|
)}
|
||||||
|
<div>字符数: {doc1Info.textLength} 个</div>
|
||||||
|
<div>置信度: {(doc1Info.confidence * 100).toFixed(0)}%</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ color: '#999' }}>未加载</div>
|
<div style={{ color: '#999' }}>未加载</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PDF 2 信息 */}
|
{/* 文档 2 信息 */}
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>📄 文档2(右侧/修改)</div>
|
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>📄 文档2(右侧/修改)</div>
|
||||||
{isLoadingPdf2 ? (
|
{isLoadingDoc2 ? (
|
||||||
<div style={{ color: '#666' }}>⏳ 加载中...</div>
|
<div style={{ color: '#666' }}>⏳ 加载中...</div>
|
||||||
) : pdf2Info ? (
|
) : doc2Info ? (
|
||||||
<div>
|
<div>
|
||||||
<div>类型: <span style={{
|
<div>类型: <span style={{
|
||||||
color: pdf2Info.type === 'text' ? '#28a745' : pdf2Info.type === 'scanned' ? '#ffc107' : '#dc3545',
|
color: doc2Info.fileType === 'pdf' ? '#007bff' : doc2Info.fileType === 'docx' ? '#28a745' : '#dc3545',
|
||||||
fontWeight: 'bold'
|
fontWeight: 'bold'
|
||||||
}}>
|
}}>
|
||||||
{pdf2Info.type === 'text' ? '✅ 文本PDF' : pdf2Info.type === 'scanned' ? '⚠️ 扫描PDF' : '❌ 未知类型'}
|
{doc2Info.fileType === 'pdf' ? '📕 PDF文档' : doc2Info.fileType === 'docx' ? '📘 Word文档' : '❌ 未知类型'}
|
||||||
</span></div>
|
</span></div>
|
||||||
<div>页数: {pdf2Info.numPages} 页</div>
|
{doc2Info.fileType === 'pdf' && doc2Info.numPages && (
|
||||||
<div>字符数: {pdf2Info.textLength} 个</div>
|
<div>页数: {doc2Info.numPages} 页</div>
|
||||||
<div>置信度: {(pdf2Info.confidence * 100).toFixed(0)}%</div>
|
)}
|
||||||
|
{doc2Info.fileType === 'pdf' && doc2Info.pdfType && (
|
||||||
|
<div>PDF类型: {doc2Info.pdfType === 'text' ? '✅ 文本' : doc2Info.pdfType === 'scanned' ? '⚠️ 扫描' : '❌ 未知'}</div>
|
||||||
|
)}
|
||||||
|
<div>字符数: {doc2Info.textLength} 个</div>
|
||||||
|
<div>置信度: {(doc2Info.confidence * 100).toFixed(0)}%</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ color: '#999' }}>未加载</div>
|
<div style={{ color: '#999' }}>未加载</div>
|
||||||
@@ -528,16 +636,16 @@ export default function MonacoDemoPage() {
|
|||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<strong>差异高亮说明:</strong>
|
<strong>差异高亮说明:</strong>
|
||||||
<ul style={{ margin: '4px 0 0 0', paddingLeft: '20px' }}>
|
<ul style={{ margin: '4px 0 0 0', paddingLeft: '20px' }}>
|
||||||
<li><span style={{ color: '#28a745', fontWeight: 'bold' }}>绿色</span>:新增的内容</li>
|
<li><span style={{ color: '#dc3545', fontWeight: 'bold' }}>左侧红色背景</span>:原始版本的内容(在新版本中被删除或修改前的内容)</li>
|
||||||
<li><span style={{ color: '#dc3545', fontWeight: 'bold' }}>红色</span>:删除的内容</li>
|
<li><span style={{ color: '#28a745', fontWeight: 'bold' }}>右侧绿色背景</span>:修改版本的内容(新增或修改后的内容)</li>
|
||||||
<li><span style={{ color: '#ffc107', fontWeight: 'bold' }}>黄色背景</span>:修改的行内差异</li>
|
<li><span style={{ color: '#666', fontWeight: 'bold' }}>深色高亮</span>:行内具体修改的字符差异</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{useExample && (
|
{useExample && (
|
||||||
<div style={{ marginTop: '12px', paddingTop: '12px', borderTop: '1px solid #b3d9ff' }}>
|
<div style={{ marginTop: '12px', paddingTop: '12px', borderTop: '1px solid #b3d9ff' }}>
|
||||||
<strong>💡 使用提示:</strong>
|
<strong>💡 使用提示:</strong>
|
||||||
<div style={{ marginTop: '4px' }}>
|
<div style={{ marginTop: '4px' }}>
|
||||||
您可以通过URL参数加载PDF文档进行对比:
|
您可以通过URL参数加载文档进行对比(支持 PDF 和 Word,使用相对路径):
|
||||||
<code style={{
|
<code style={{
|
||||||
display: 'block',
|
display: 'block',
|
||||||
marginTop: '4px',
|
marginTop: '4px',
|
||||||
@@ -547,10 +655,11 @@ export default function MonacoDemoPage() {
|
|||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
wordBreak: 'break-all'
|
wordBreak: 'break-all'
|
||||||
}}>
|
}}>
|
||||||
/monaco-demo?pdf1=路径1&pdf2=路径2
|
/monaco-demo?doc1=相对路径1&doc2=相对路径2
|
||||||
</code>
|
</code>
|
||||||
<div style={{ marginTop: '4px', fontSize: '12px' }}>
|
<div style={{ marginTop: '4px', fontSize: '12px' }}>
|
||||||
示例: <code>/monaco-demo?pdf1=documents/contract_v1.pdf&pdf2=documents/contract_v2.pdf</code>
|
<div>Word示例: <code>/monaco-demo?doc1=testWork/(最终版)智慧法务平台建设采购项目合同(1).docx&doc2=testWork/(最终版)智慧法务平台建设采购项目合同(2).docx</code></div>
|
||||||
|
<div style={{ marginTop: '2px' }}>PDF示例: <code>/monaco-demo?doc1=testPDF/sample1.pdf&doc2=testPDF/sample2.pdf</code></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -591,8 +700,8 @@ export default function MonacoDemoPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* PDF加载中的遮罩层 */}
|
{/* 文档加载中的遮罩层 */}
|
||||||
{(isLoadingPdf1 || isLoadingPdf2) && (
|
{(isLoadingDoc1 || isLoadingDoc2) && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
@@ -616,10 +725,10 @@ export default function MonacoDemoPage() {
|
|||||||
margin: '0 auto 16px'
|
margin: '0 auto 16px'
|
||||||
}}></div>
|
}}></div>
|
||||||
<div style={{ fontSize: '16px', color: '#333' }}>
|
<div style={{ fontSize: '16px', color: '#333' }}>
|
||||||
正在加载PDF文档并提取文本...
|
正在加载文档并提取文本...
|
||||||
</div>
|
</div>
|
||||||
{isLoadingPdf1 && <div style={{ fontSize: '14px', color: '#666', marginTop: '8px' }}>📄 加载文档1</div>}
|
{isLoadingDoc1 && <div style={{ fontSize: '14px', color: '#666', marginTop: '8px' }}>📄 加载文档1</div>}
|
||||||
{isLoadingPdf2 && <div style={{ fontSize: '14px', color: '#666', marginTop: '8px' }}>📄 加载文档2</div>}
|
{isLoadingDoc2 && <div style={{ fontSize: '14px', color: '#666', marginTop: '8px' }}>📄 加载文档2</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+13
-72
@@ -39,7 +39,7 @@ interface HighlightArea {
|
|||||||
|
|
||||||
// 基于坐标的字符数据
|
// 基于坐标的字符数据
|
||||||
interface CharacterBox {
|
interface CharacterBox {
|
||||||
box: [number, number][]; // 4个点:左上、右上、右下、左下
|
box: [number, number][];
|
||||||
char: string;
|
char: string;
|
||||||
page: number;
|
page: number;
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,7 @@ export default function PdfDemo() {
|
|||||||
// PDF文件URL(使用示例PDF)
|
// PDF文件URL(使用示例PDF)
|
||||||
// const [pdfUrl] = useState('/testPDF/sample.pdf'); // 使用包含真实文本层的PDF
|
// const [pdfUrl] = useState('/testPDF/sample.pdf'); // 使用包含真实文本层的PDF
|
||||||
// const [pdfUrl] = useState('/api/pdf-proxy?path=documents/mz/行政处罚决定书/2025/11月13日/第71号--未在当地烟草专卖批发企业进货_02时58分36秒/第71号--未在当地烟草专卖批发企业进货.pdf'); // 使用项目中的示例PDF
|
// const [pdfUrl] = useState('/api/pdf-proxy?path=documents/mz/行政处罚决定书/2025/11月13日/第71号--未在当地烟草专卖批发企业进货_02时58分36秒/第71号--未在当地烟草专卖批发企业进货.pdf'); // 使用项目中的示例PDF
|
||||||
const [pdfUrl] = useState('/api/pdf-proxy?path=documents/mz/行政处罚决定书/2025/11月22日/第35号--无烟草专卖品准运证运输烟草专卖品_15时15分24秒/第35号--无烟草专卖品准运证运输烟草专卖品.pdf')
|
const [pdfUrl] = useState('/api/pdf-proxy?path=documents/mz/测试示范类型/2025/11月24日/第37号--涉嫌生产、销售伪劣产品罪_12时19分10秒/第37号--涉嫌生产、销售伪劣产品罪.pdf')
|
||||||
|
|
||||||
// PDF状态
|
// PDF状态
|
||||||
const [numPages, setNumPages] = useState<number | null>(null);
|
const [numPages, setNumPages] = useState<number | null>(null);
|
||||||
@@ -227,87 +227,28 @@ export default function PdfDemo() {
|
|||||||
// 获取Page容器(SVG实际渲染的坐标空间)
|
// 获取Page容器(SVG实际渲染的坐标空间)
|
||||||
const pageContainer = canvas?.closest('.react-pdf__Page') as HTMLElement;
|
const pageContainer = canvas?.closest('.react-pdf__Page') as HTMLElement;
|
||||||
|
|
||||||
if (canvas && pageContainer && pdfOriginalWidthPt) {
|
if (canvas && pdfOriginalWidthPt) {
|
||||||
// Canvas 内部绘制尺寸(考虑了 devicePixelRatio)
|
|
||||||
const canvasInternalWidth = canvas.width;
|
|
||||||
const canvasInternalHeight = canvas.height;
|
|
||||||
|
|
||||||
// Canvas 显示尺寸(浏览器中实际占用的像素)
|
// Canvas 显示尺寸(浏览器中实际占用的像素)
|
||||||
const canvasDisplayWidth = canvas.offsetWidth;
|
const canvasDisplayWidth = canvas.offsetWidth;
|
||||||
const canvasDisplayHeight = canvas.offsetHeight;
|
const canvasDisplayHeight = canvas.offsetHeight;
|
||||||
|
|
||||||
// Page容器尺寸(SVG高亮渲染的实际坐标空间)
|
// 计算坐标缩放比例:Canvas显示尺寸 / PDF原始尺寸
|
||||||
const pageContainerWidth = pageContainer.offsetWidth;
|
const autoScale = canvasDisplayWidth / pdfOriginalWidthPt;
|
||||||
const pageContainerHeight = pageContainer.offsetHeight;
|
|
||||||
|
|
||||||
// 尝试多种计算方式
|
console.log('📏 PDF尺寸信息:');
|
||||||
const scale1_canvasDisplay = canvasDisplayWidth / pdfOriginalWidthPt;
|
console.log(' - PDF原始尺寸 (page.view):', pdfOriginalWidthPt, 'x', pdfOriginalHeightPt, 'pt');
|
||||||
const scale2_canvasInternal = canvasInternalWidth / pdfOriginalWidthPt;
|
console.log(' - Canvas显示尺寸 (offsetWidth):', canvasDisplayWidth, 'x', canvasDisplayHeight, 'px');
|
||||||
const scale3_pageContainer = pageContainerWidth / pdfOriginalWidthPt;
|
console.log(' - 用户缩放 (scale):', scale);
|
||||||
|
console.log(' - devicePixelRatio:', window.devicePixelRatio || 1);
|
||||||
// 尝试反向计算:如果OCR尺寸比渲染尺寸大(需要缩小)
|
console.log('🎯 自动计算坐标缩放:', autoScale.toFixed(3), 'x');
|
||||||
const scale4_inverseCanvasInternal = canvasDisplayWidth / canvasInternalWidth;
|
console.log(' 公式: Canvas显示宽度 / PDF原始宽度 =', canvasDisplayWidth, '/', pdfOriginalWidthPt);
|
||||||
const scale5_inversePage = canvasDisplayWidth / pageContainerWidth;
|
|
||||||
|
|
||||||
// 计算如果要达到 0.83 的缩放比例,OCR原始尺寸应该是多少
|
|
||||||
const expectedOcrWidth = canvasDisplayWidth / 0.83;
|
|
||||||
|
|
||||||
console.log('📏 尺寸信息汇总:');
|
|
||||||
console.log(' 1️⃣ PDF原始尺寸 (page.view):', pdfOriginalWidthPt, 'x', pdfOriginalHeightPt, 'pt');
|
|
||||||
console.log(' 2️⃣ Page容器尺寸:', pageContainerWidth, 'x', pageContainerHeight, 'px');
|
|
||||||
console.log(' 3️⃣ Canvas显示尺寸:', canvasDisplayWidth, 'x', canvasDisplayHeight, 'px');
|
|
||||||
console.log(' 4️⃣ Canvas内部尺寸:', canvasInternalWidth, 'x', canvasInternalHeight, 'px');
|
|
||||||
console.log(' 5️⃣ 用户缩放 (scale):', scale);
|
|
||||||
console.log(' 6️⃣ devicePixelRatio:', window.devicePixelRatio || 1);
|
|
||||||
console.log('');
|
|
||||||
console.log('🎯 各种计算方式:');
|
|
||||||
console.log(' 方案1️⃣: Canvas显示 / PDF原始 =', scale1_canvasDisplay.toFixed(3), 'x');
|
|
||||||
console.log(' 方案2️⃣: Canvas内部 / PDF原始 =', scale2_canvasInternal.toFixed(3), 'x');
|
|
||||||
console.log(' 方案3️⃣: Page容器 / PDF原始 =', scale3_pageContainer.toFixed(3), 'x');
|
|
||||||
console.log(' 方案4️⃣: Canvas显示 / Canvas内部 =', scale4_inverseCanvasInternal.toFixed(3), 'x ⬅ 可能是这个!');
|
|
||||||
console.log(' 方案5️⃣: Canvas显示 / Page容器 =', scale5_inversePage.toFixed(3), 'x');
|
|
||||||
console.log('');
|
|
||||||
console.log('🔍 目标值分析:');
|
|
||||||
console.log(' - 手动校准的正确值: 0.83');
|
|
||||||
console.log(' - 反推OCR图像尺寸:', expectedOcrWidth.toFixed(0), 'x', (canvasDisplayHeight / 0.83).toFixed(0), 'px');
|
|
||||||
console.log(' - 比较: ', expectedOcrWidth.toFixed(0), 'vs Canvas内部', canvasInternalWidth);
|
|
||||||
|
|
||||||
// 使用最接近0.83的方案
|
|
||||||
let autoScale = scale1_canvasDisplay;
|
|
||||||
let scaleMethod = '方案1 (Canvas显示/PDF原始)';
|
|
||||||
|
|
||||||
// 检查哪个方案最接近0.83
|
|
||||||
const diff1 = Math.abs(scale1_canvasDisplay - 0.83);
|
|
||||||
const diff2 = Math.abs(scale2_canvasInternal - 0.83);
|
|
||||||
const diff3 = Math.abs(scale3_pageContainer - 0.83);
|
|
||||||
const diff4 = Math.abs(scale4_inverseCanvasInternal - 0.83);
|
|
||||||
const diff5 = Math.abs(scale5_inversePage - 0.83);
|
|
||||||
|
|
||||||
const minDiff = Math.min(diff1, diff2, diff3, diff4, diff5);
|
|
||||||
|
|
||||||
if (minDiff === diff4) {
|
|
||||||
autoScale = scale4_inverseCanvasInternal;
|
|
||||||
scaleMethod = '方案4 (Canvas显示/Canvas内部)';
|
|
||||||
} else if (minDiff === diff5) {
|
|
||||||
autoScale = scale5_inversePage;
|
|
||||||
scaleMethod = '方案5 (Canvas显示/Page容器)';
|
|
||||||
} else if (minDiff === diff2) {
|
|
||||||
autoScale = scale2_canvasInternal;
|
|
||||||
scaleMethod = '方案2 (Canvas内部/PDF原始)';
|
|
||||||
} else if (minDiff === diff3) {
|
|
||||||
autoScale = scale3_pageContainer;
|
|
||||||
scaleMethod = '方案3 (Page容器/PDF原始)';
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('');
|
|
||||||
console.log('✅ 自动选择:', scaleMethod, '=', autoScale.toFixed(3), 'x (最接近0.83)');
|
|
||||||
|
|
||||||
// 保存原始宽度和自动计算的缩放比例
|
// 保存原始宽度和自动计算的缩放比例
|
||||||
setPdfOriginalWidth(pdfOriginalWidthPt);
|
setPdfOriginalWidth(pdfOriginalWidthPt);
|
||||||
setCoordinateScale(autoScale);
|
setCoordinateScale(autoScale);
|
||||||
setIsScaleAutoCalculated(true);
|
setIsScaleAutoCalculated(true);
|
||||||
|
|
||||||
toastService.success(`自动校准完成: ${autoScale.toFixed(3)}x (${scaleMethod})`);
|
toastService.success(`自动校准完成: ${autoScale.toFixed(3)}x`);
|
||||||
} else {
|
} else {
|
||||||
console.warn('⚠️ 无法获取Canvas元素、Page容器或原始尺寸');
|
console.warn('⚠️ 无法获取Canvas元素、Page容器或原始尺寸');
|
||||||
console.log('调试信息:', {
|
console.log('调试信息:', {
|
||||||
|
|||||||
+53
-21
@@ -178,8 +178,10 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const id = url.searchParams.get('id') || undefined;
|
const id = url.searchParams.get('id') || undefined;
|
||||||
const previousRoute = url.searchParams.get('previousRoute') || '';
|
const previousRoute = url.searchParams.get('previousRoute') || '';
|
||||||
// console.log("id-------",id);
|
// console.log("[Reviews Loader] 开始加载,id:", id, "previousRoute:", previousRoute);
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
|
console.error("[Reviews Loader] 文件ID不能为空");
|
||||||
return Response.json({ result: false, message: '文件ID不能为空' });
|
return Response.json({ result: false, message: '文件ID不能为空' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,16 +192,13 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
// 获取评查点数据,传递request对象
|
// 获取评查点数据,传递request对象
|
||||||
const reviewData = await getReviewPoints(id, request);
|
const reviewData = await getReviewPoints(id, request);
|
||||||
|
|
||||||
// console.log("documentData-------",JSON.stringify(documentData.data,null,2));
|
|
||||||
// console.log("reviewData-------",JSON.stringify('data' in reviewData ? reviewData.data : '',null,2));
|
|
||||||
if ('error' in reviewData && reviewData.error) {
|
if ('error' in reviewData && reviewData.error) {
|
||||||
console.error("获取评查点数据错误:", reviewData.error);
|
console.error("[Reviews Loader] 获取评查点数据错误:", reviewData.error);
|
||||||
return Response.json({ result: false, message: reviewData.error });
|
return Response.json({ result: false, message: reviewData.error });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保reviewData有效且具有预期的属性
|
// 确保reviewData有效且具有预期的属性
|
||||||
if ('document' in reviewData && 'data' in reviewData && 'reviewInfo' in reviewData && 'stats' in reviewData) {
|
if ('document' in reviewData && 'data' in reviewData && 'reviewInfo' in reviewData && 'stats' in reviewData) {
|
||||||
// console.log("reviewData-------",JSON.stringify(reviewData.data));
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
previousRoute: previousRoute,
|
previousRoute: previousRoute,
|
||||||
document: reviewData.document,
|
document: reviewData.document,
|
||||||
@@ -211,12 +210,13 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
frontendJWT
|
frontendJWT
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.error("返回的评查数据格式不正确",JSON.stringify(reviewData,null,2));
|
console.error("[Reviews Loader] 返回的评查数据格式不正确,完整数据:", JSON.stringify(reviewData, null, 2));
|
||||||
return Response.json({ result: false, message: '返回的评查数据格式不正确' });
|
return Response.json({ result: false, message: '返回的评查数据格式不正确' });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取评查数据失败:', error);
|
console.error('[Reviews Loader] 获取评查数据失败:', error);
|
||||||
return Response.json({ result: false, message: '获取评查数据失败' });
|
console.error('[Reviews Loader] 错误堆栈:', error instanceof Error ? error.stack : '无堆栈信息');
|
||||||
|
return Response.json({ result: false, message: `获取评查数据失败: ${error instanceof Error ? error.message : '未知错误'}` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +226,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const intent = formData.get("intent") as string;
|
const intent = formData.get("intent") as string;
|
||||||
|
|
||||||
console.log('Action接收到请求, intent:', intent);
|
// console.log('Action接收到请求, intent:', intent);
|
||||||
|
|
||||||
if (intent === "updateReviewResult") {
|
if (intent === "updateReviewResult") {
|
||||||
const reviewPointResultId = formData.get("reviewPointResultId") as string;
|
const reviewPointResultId = formData.get("reviewPointResultId") as string;
|
||||||
@@ -292,23 +292,39 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
export default function ReviewDetails() {
|
export default function ReviewDetails() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const loaderData = useLoaderData<typeof loader>();
|
const loaderData = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
// 调试:查看loaderData内容 - 强制刷新
|
||||||
|
console.log('[Reviews Component] loaderData keys:', Object.keys(loaderData));
|
||||||
|
console.log('[Reviews Component] loaderData:', loaderData);
|
||||||
|
|
||||||
const fetcher = useFetcher();
|
const fetcher = useFetcher();
|
||||||
const { document, reviewPoints, statistics, reviewInfo, comparison_document, frontendJWT } = loaderData;
|
const { document, reviewPoints, statistics, reviewInfo, comparison_document, frontendJWT } = loaderData;
|
||||||
|
|
||||||
|
// 调试:查看解构后的数据
|
||||||
|
console.log('[Reviews Component] 解构后的document:', document);
|
||||||
|
console.log('[Reviews Component] 解构后的reviewPoints length:', reviewPoints?.length);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false); // 已经通过loader加载了数据,不需要再显示加载状态
|
const [isLoading, setIsLoading] = useState(false); // 已经通过loader加载了数据,不需要再显示加载状态
|
||||||
const [activeTab, setActiveTab] = useState<string>('preview'); // 'preview', 'analysis', 'fileinfo'
|
const [activeTab, setActiveTab] = useState<string>('preview'); // 'preview', 'analysis', 'fileinfo'
|
||||||
const [reviewData, setReviewData] = useState<ReviewData | null>(null);
|
const [reviewData, setReviewData] = useState<ReviewData | null>(null);
|
||||||
const [activeReviewPointResultId, setActiveReviewPointResultId] = useState<string | null>(null);
|
const [activeReviewPointResultId, setActiveReviewPointResultId] = useState<string | null>(null);
|
||||||
const [targetPage, setTargetPage] = useState<number | undefined>(undefined);
|
const [targetPage, setTargetPage] = useState<number | undefined>(undefined);
|
||||||
const [templateTargetPage, setTemplateTargetPage] = 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 [pendingUpdate, setPendingUpdate] = useState<{
|
const [pendingUpdate, setPendingUpdate] = useState<{
|
||||||
reviewPointResultId: string;
|
reviewPointResultId: string;
|
||||||
newStatus: string;
|
newStatus: string;
|
||||||
message: string;
|
message: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// loader 数据加载出错
|
// loader 数据加载出错
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
loadingBarService.hide();
|
loadingBarService.hide();
|
||||||
|
console.log('[Reviews Component] useEffect检查loaderData:', {
|
||||||
|
hasResultKey: Object.keys(loaderData).find(key => key === 'result'),
|
||||||
|
resultValue: loaderData.result,
|
||||||
|
willNavigateBack: Object.keys(loaderData).find(key => key === 'result') && !loaderData.result
|
||||||
|
});
|
||||||
if(Object.keys(loaderData).find(key => key === 'result') && !loaderData.result){
|
if(Object.keys(loaderData).find(key => key === 'result') && !loaderData.result){
|
||||||
messageService.show({
|
messageService.show({
|
||||||
title: '错误',
|
title: '错误',
|
||||||
@@ -367,19 +383,22 @@ export default function ReviewDetails() {
|
|||||||
setActiveTab(tabKey);
|
setActiveTab(tabKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReviewPointSelect = (reviewPointId: string, page?: number) => {
|
const handleReviewPointSelect = (reviewPointId: string, page?: number, charPos?: Array<{ box: number[][], char: string, score: number }>) => {
|
||||||
// 如果点击的是相同的评查点,但有page参数,先重置targetPage以确保useEffect能够触发
|
// 如果点击的是相同的评查点,但有page参数,先重置targetPage以确保useEffect能够触发
|
||||||
if (reviewPointId === activeReviewPointResultId && page) {
|
if (reviewPointId === activeReviewPointResultId && page) {
|
||||||
setTargetPage(undefined);
|
setTargetPage(undefined);
|
||||||
// 使用setTimeout确保状态更新后再设置新的targetPage
|
setCharPositions(undefined);
|
||||||
|
// 使用setTimeout确保状态更新后再设置新的targetPage和charPositions
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setActiveReviewPointResultId(reviewPointId);
|
setActiveReviewPointResultId(reviewPointId);
|
||||||
setTargetPage(page);
|
setTargetPage(page);
|
||||||
|
setCharPositions(charPos);
|
||||||
}, 0);
|
}, 0);
|
||||||
} else {
|
} else {
|
||||||
// 正常设置activeReviewPointId和targetPage
|
// 正常设置activeReviewPointId、targetPage和charPositions
|
||||||
setActiveReviewPointResultId(reviewPointId);
|
setActiveReviewPointResultId(reviewPointId);
|
||||||
setTargetPage(page);
|
setTargetPage(page);
|
||||||
|
setCharPositions(charPos);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -713,14 +732,26 @@ export default function ReviewDetails() {
|
|||||||
<div className="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:space-x-4">
|
<div className="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:space-x-4">
|
||||||
{/* 左侧:文件预览 */}
|
{/* 左侧:文件预览 */}
|
||||||
<div className="w-full lg:w-[65%]">
|
<div className="w-full lg:w-[65%]">
|
||||||
<FilePreview
|
{(() => {
|
||||||
fileContent={document}
|
console.log('[Reviews] 准备渲染FilePreview', {
|
||||||
reviewPoints={reviewData.reviewPoints}
|
hasDocument: !!document,
|
||||||
activeReviewPointResultId={activeReviewPointResultId}
|
documentPath: document?.path,
|
||||||
targetPage={targetPage}
|
targetPage,
|
||||||
/>
|
hasCharPositions: !!charPositions,
|
||||||
|
charPositionsLength: charPositions?.length
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<FilePreview
|
||||||
|
fileContent={document}
|
||||||
|
reviewPoints={reviewData.reviewPoints}
|
||||||
|
activeReviewPointResultId={activeReviewPointResultId}
|
||||||
|
targetPage={targetPage}
|
||||||
|
charPositions={charPositions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右侧:评查结果 */}
|
{/* 右侧:评查结果 */}
|
||||||
<div className="w-full lg:w-[35%]">
|
<div className="w-full lg:w-[35%]">
|
||||||
<ReviewPointsList
|
<ReviewPointsList
|
||||||
@@ -739,11 +770,12 @@ export default function ReviewDetails() {
|
|||||||
<div className="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:space-x-4">
|
<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%]'}`}>
|
<div className={`w-full ${comparison_document.template_contract_path ? 'lg:w-[38%]' : 'lg:w-[56%]'}`}>
|
||||||
<FilePreview
|
<FilePreview
|
||||||
fileContent={document}
|
fileContent={document}
|
||||||
reviewPoints={reviewData.reviewPoints}
|
reviewPoints={reviewData.reviewPoints}
|
||||||
activeReviewPointResultId={activeReviewPointResultId}
|
activeReviewPointResultId={activeReviewPointResultId}
|
||||||
targetPage={targetPage}
|
targetPage={targetPage}
|
||||||
|
charPositions={charPositions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Generated
+317
-774
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user