Files
leaudit-platform-frontend/app/components/collabora/CollaboraViewer.tsx
T
LiangShiyong 6fa65ff156 fix: 1. 优化collabora的高亮效果,不固定主要页面。
2. 优化评查结果中的下载按钮,如果加载docx文件的话需要先保存再下载。
3. 交叉评查结果中添加返回按钮,并实现打开对应的任务的文档列表。
4. 文档类型的添加,添加绑定合同管理为入口的时候文档类型名称必须是要附带‘合同’字符。
2025-12-17 01:09:23 +08:00

1198 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Collabora Online 文档查看器组件
*
* 功能:
* - 加载 Collabora Online iframe
* - 管理文档加载状态
* - 提供 UNO 命令接口
* - 支持只读和编辑模式
*
* @encoding UTF-8
*/
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { useCollaboraConfig, useCollaboraUnoCommands, useDocumentReady } from './hooks';
import {
customGotoPage,
highlightText as pythonHighlightText,
replaceTextInPage,
requestPageInfo,
unoCancelSearch,
unoHighlightText,
unoReplaceAll,
unoReplaceCurrent,
unoScrollToTop,
unoSearchNext,
type GotoPageResponse,
type PageInfo
} from './lib';
import { clearHighlights } from './lib/ClearHighlight';
import type { CollaboraViewerHandle, CollaboraViewerProps } from './types';
import { sendUnoCommand } from './Uno';
/**
* Collabora 文档查看器组件
* @param props - 组件属性
* @param ref - 父组件传入的 ref,用于暴露命令接口
*/
export const CollaboraViewer = forwardRef<CollaboraViewerHandle, CollaboraViewerProps>(
function CollaboraViewer(
{
fileId,
mode = 'view',
userId = 'guest',
userName = '',
targetPage,
highlightText,
aiSuggestionReplace,
},
ref
) {
const iframeRef = useRef<HTMLIFrameElement>(null);
// 保存 iframe 的 contentWindow 引用,用于组件卸载时清除高亮
const iframeWindowRef = useRef<Window | null>(null);
// 搜索替换面板显示状态
const [showSearchReplacePanel, setShowSearchReplacePanel] = useState(false);
// 标记是否应该自动执行搜索
const shouldAutoSearchRef = useRef(false);
// 标记是否应该自动执行替换(静默模式)
const shouldAutoReplaceRef = useRef(false);
// 高亮测试面板状态
const [highlightTextInput, setHighlightTextInput] = useState('');
const [highlightPage, setHighlightPage] = useState('');
const [previousHighlightText, setPreviousHighlightText] = useState<string | null>(null);
const [highlightResult, setHighlightResult] = useState<string | null>(null);
// 清除高亮测试面板状态
const [clearHighlightResult, setClearHighlightResult] = useState<string | null>(null);
const [isClearing, setIsClearing] = useState(false);
// 页数信息测试状态
const [pageInfoResult, setPageInfoResult] = useState<string | null>(null);
const [isLoadingPageInfo, setIsLoadingPageInfo] = useState(false);
// 页面跳转测试状态
const [gotoPageInput, setGotoPageInput] = useState('');
const [gotoPageResult, setGotoPageResult] = useState<string | null>(null);
const [isJumping, setIsJumping] = useState(false);
// UNO 命令测试面板状态
const [unoCmd, setUnoCmd] = useState('.uno:Bold');
const [unoArgs, setUnoArgs] = useState('');
const [unoResult, setUnoResult] = useState<string | null>(null);
// 搜索替换测试面板状态
const [searchText, setSearchText] = useState('');
const [replaceText, setReplaceText] = useState('');
const [searchReplaceResult, setSearchReplaceResult] = useState<string | null>(null);
const [replaceAllMode, setReplaceAllMode] = useState(false);
const [searchReplacePageNumber, setSearchReplacePageNumber] = useState('');
// 记录上一次搜索的参数,用于判断是否需要重新跳转页面
const [lastSearchParams, setLastSearchParams] = useState<{
text: string;
page: string;
}>({ text: '', page: '' });
// 页面定点替换状态
const [replaceInPageNumber, setReplaceInPageNumber] = useState('');
const [replaceInPageSearch, setReplaceInPageSearch] = useState('');
const [replaceInPageReplace, setReplaceInPageReplace] = useState('');
const [replaceInPageResult, setReplaceInPageResult] = useState<string | null>(null);
const [isReplacingInPage, setIsReplacingInPage] = useState(false);
// 1. 加载 Collabora 配置
const { config, loading, error } = useCollaboraConfig(fileId, mode, userId, userName);
// 2. 监听文档加载状态
const { isDocumentLoaded } = useDocumentReady(iframeRef);
// 2.5. 保存 iframe window 引用并在文档加载时清除所有高亮
useEffect(() => {
if (isDocumentLoaded && iframeRef.current?.contentWindow) {
iframeWindowRef.current = iframeRef.current.contentWindow;
// console.log('[CollaboraViewer] 已保存 iframe window 引用');
// 🔥 文档加载完成后主动清除一次高亮(防止缓存的高亮状态)
// console.log('[CollaboraViewer] 🧹 文档加载完成,清除可能存在的缓存高亮');
clearHighlights(iframeRef.current.contentWindow, {
color: 16776960,
timeout: 5000,
}).then((result) => {
if (result.count && result.count > 0) {
// console.log(`[CollaboraViewer] ✓ 清除了 ${result.count} 个缓存的高亮区域`);
} else {
// console.log('[CollaboraViewer] ✓ 文档无缓存高亮,已确认干净');
}
}).catch(error => {
console.warn('[CollaboraViewer] ⚠️ 清除缓存高亮失败:', error);
});
}
}, [isDocumentLoaded]);
// 3. UNO 命令封装
const unoCommands = useCollaboraUnoCommands(iframeRef);
// 4. 暴露接口给父组件(包括清除高亮方法和保存方法)
useImperativeHandle(ref, () => ({
unoCommands,
isReady: isDocumentLoaded,
mode,
getIframeWindow: () => iframeRef.current?.contentWindow || null,
clearAllHighlights: async () => {
const savedWindow = iframeWindowRef.current || iframeRef.current?.contentWindow;
if (savedWindow) {
// console.log('[CollaboraViewer] 🧹 父组件调用清除高亮');
await clearHighlights(savedWindow, {
color: 16776960,
timeout: 5000,
});
// console.log('[CollaboraViewer] ✓ 清除高亮完成');
} else {
console.warn('[CollaboraViewer] ⚠️ 无法清除高亮:iframe window 不可用');
}
},
saveDocument: async () => {
const savedWindow = iframeWindowRef.current || iframeRef.current?.contentWindow;
if (savedWindow) {
console.log('[CollaboraViewer] 💾 父组件调用保存文档');
try {
// 步骤1:发送保存命令
sendUnoCommand(savedWindow, '.uno:Save', {});
// console.log('[CollaboraViewer] ✓ 保存命令已发送');
// 步骤2:等待 WOPI PutFile 请求完成(增加到 2000ms
// await new Promise(resolve => setTimeout(resolve, 2000));
// 步骤3:再次发送保存命令确保完全保存
sendUnoCommand(savedWindow, '.uno:Save', {});
// console.log('[CollaboraViewer] ✓ 二次保存命令已发送');
// 步骤4:再等待一段时间确保保存完成
// await new Promise(resolve => setTimeout(resolve, 1000));
// console.log('[CollaboraViewer] ✓ 文档保存完成(总等待 3000ms');
} catch (error) {
console.error('[CollaboraViewer] ✗ 保存文档失败:', error);
throw error;
}
} else {
console.warn('[CollaboraViewer] ⚠️ 无法保存文档:iframe window 不可用');
throw new Error('iframe window 不可用');
}
},
}), [unoCommands, isDocumentLoaded, mode]);
// 5. 监听 targetPage 和 highlightText 变化,自动跳转并高亮
// 新逻辑:从指定页(默认第1页)开始向下搜索第一个匹配项并高亮
// - 先跳转到指定页面
// - 使用 UNO 搜索从当前位置向下找第一个匹配项(UNO 搜索自带循环功能)
// - 对搜索到的文本设置高亮背景色
useEffect(() => {
// 如果文档未加载完成,不执行跳转和高亮
if (!isDocumentLoaded || !iframeRef.current?.contentWindow) {
return;
}
// 如果有高亮文本,执行高亮操作
if (highlightText && highlightText.trim() !== '') {
const performHighlight = async () => {
const iframeWindow = iframeRef.current!.contentWindow!;
const textToHighlight = highlightText.trim();
// 确定起始页码:有 targetPage 则使用,否则默认从第1页开始
const startPage = (targetPage !== undefined && targetPage !== null) ? targetPage : 1;
try {
// 步骤1:清除之前的所有高亮
// console.log('[CollaboraViewer] 步骤1:清除旧高亮...');
await clearHighlights(iframeWindow, {
color: 16776960, // 黄色
timeout: 5000,
});
// 短暂延迟,确保清除操作完成
await new Promise(resolve => setTimeout(resolve, 100));
// 步骤2:跳转到起始页面
// console.log(`[CollaboraViewer] 步骤2:跳转到第 ${startPage} 页...`);
try {
await customGotoPage(iframeWindow, startPage);
// 等待页面跳转完成
await new Promise(resolve => setTimeout(resolve, 300));
} catch (gotoError) {
console.warn('[CollaboraViewer] 页面跳转失败,继续在当前位置搜索:', gotoError);
}
// 步骤3:使用 UNO 搜索从当前位置向下找第一个匹配项
// UNO 搜索会自动跳转到匹配位置,如果到文档末尾没找到会循环回开头继续搜索
console.log(`[CollaboraViewer] 步骤3:从第 ${startPage} 页开始向下搜索...`);
unoSearchNext(iframeWindow, textToHighlight);
// 等待搜索完成
await new Promise(resolve => setTimeout(resolve, 200));
// 步骤4:对搜索到的文本(当前选中)设置高亮背景色
// console.log('[CollaboraViewer] 步骤4:设置高亮背景色...');
sendUnoCommand(iframeWindow, '.uno:BackColor', {
BackColor: { type: 'long', value: 16776960 }, // 黄色
});
// 短暂延迟,确保高亮操作完成
await new Promise(resolve => setTimeout(resolve, 100));
// 步骤5:取消选中状态(避免高亮后文本仍被选中)
// console.log('[CollaboraViewer] 步骤5:取消选中状态...');
sendUnoCommand(iframeWindow, '.uno:Escape', {});
console.log(`[CollaboraViewer] ✓ 搜索高亮完成:(从第${startPage}页开始搜索)`);
} catch (error) {
console.error('[CollaboraViewer] 高亮失败:', error);
}
};
performHighlight();
}
}, [targetPage, highlightText, isDocumentLoaded]);
// 6. 组件销毁时保存文档并清除所有高亮(使用保存的 window 引用)
useEffect(() => {
// 返回清理函数,在组件卸载时执行
return () => {
const savedWindow = iframeWindowRef.current;
if (savedWindow) {
// console.log('[CollaboraViewer] 🔥 组件即将销毁,触发文档保存和清除高亮');
// 步骤1:发送保存命令(如果是编辑模式)
if (mode === 'edit') {
try {
console.log('[CollaboraViewer] 💾 组件销毁时发送保存命令');
sendUnoCommand(savedWindow, '.uno:Save', {});
// 再次发送确保保存
setTimeout(() => {
sendUnoCommand(savedWindow, '.uno:Save', {});
console.log('[CollaboraViewer] ✓ 二次保存命令已发送');
}, 100);
} catch (error) {
console.error('[CollaboraViewer] ✗ 组件销毁时保存失败:', error);
}
}
// 步骤2:清除高亮
void clearHighlights(savedWindow, {
color: 16776960, // 黄色
timeout: 3000,
}).then(() => {
console.log('[CollaboraViewer] ✓ 组件销毁时高亮清除成功');
}).catch(error => {
console.error('[CollaboraViewer] ✗ 组件销毁时清除高亮失败:', error);
});
// 清空引用
iframeWindowRef.current = null;
} else {
console.warn('[CollaboraViewer] ⚠️ 组件销毁时未找到保存的 window 引用');
}
};
}, [mode]);
// 7. 监听 AI 建议替换参数变化,设置搜索参数
useEffect(() => {
if (!aiSuggestionReplace || !isDocumentLoaded) {
return;
}
console.log('[CollaboraViewer] 收到 AI 建议替换参数:', aiSuggestionReplace);
const { searchText: newSearchText, replaceText: newReplaceText, pageNumber, silentReplace } = aiSuggestionReplace;
// 根据 silentReplace 标志决定是否显示面板
if (silentReplace) {
// 静默替换模式:不显示面板,直接执行替换
console.log('[CollaboraViewer] 静默替换模式,不显示面板,直接执行替换');
// 延迟执行替换,确保 DOM 更新完成
const timer = setTimeout(async () => {
if (!iframeRef.current?.contentWindow) {
console.error('[CollaboraViewer] iframe 未就绪,无法执行替换');
return;
}
try {
// 步骤1:搜索文本(确保文本被选中,会自动跳转到匹配位置)
console.log(`[CollaboraViewer] 步骤1:搜索文本 "${newSearchText}"`);
unoSearchNext(iframeRef.current.contentWindow, newSearchText);
// 等待搜索完成
await new Promise(resolve => setTimeout(resolve, 300));
// 步骤2:执行替换(替换后光标保留在当前位置)
console.log(`[CollaboraViewer] 步骤2:替换为 "${newReplaceText}"`);
unoReplaceCurrent(iframeRef.current.contentWindow, newSearchText, newReplaceText);
// 等待替换完成
await new Promise(resolve => setTimeout(resolve, 200));
// 步骤3:自动搜索下一个相同的占位符(如果还有的话)
// console.log(`[CollaboraViewer] 步骤3:定位到下一个 "${newSearchText}"`);
unoSearchNext(iframeRef.current.contentWindow, newSearchText);
console.log('[CollaboraViewer] ✓ 静默替换完成,已定位到下一个占位符(如有)');
} catch (error) {
console.error('[CollaboraViewer] 静默替换失败:', error);
}
}, 300);
return () => clearTimeout(timer);
} else {
// 显示搜索替换面板
setShowSearchReplacePanel(true);
// 设置搜索、替换和页码输入框的值
setSearchText(newSearchText);
setReplaceText(newReplaceText);
setSearchReplacePageNumber(String(pageNumber));
// 普通模式:仅自动执行查找
shouldAutoSearchRef.current = true;
console.log('[CollaboraViewer] 已设置搜索参数,等待状态更新后自动执行查找');
}
}, [aiSuggestionReplace, isDocumentLoaded]);
// 8. 当搜索参数更新完成后,自动执行查找
useEffect(() => {
if (shouldAutoSearchRef.current && searchText && searchReplacePageNumber && isDocumentLoaded) {
console.log('[CollaboraViewer] 状态更新完成,执行自动查找:', { searchText, searchReplacePageNumber });
// 重置标志
shouldAutoSearchRef.current = false;
// 延迟执行,确保 DOM 更新完成
const timer = setTimeout(() => {
// console.log('[CollaboraViewer] 开始执行自动查找操作');
handleSearchNext();
}, 100);
return () => clearTimeout(timer);
}
}, [searchText, searchReplacePageNumber, isDocumentLoaded]); // eslint-disable-line react-hooks/exhaustive-deps
// 9. 当搜索参数更新完成后,自动执行替换(静默模式)
// 不跳转页面,直接在当前位置搜索并替换,替换后自动定位到下一个相同占位符
useEffect(() => {
if (shouldAutoReplaceRef.current && searchText && replaceText && isDocumentLoaded) {
console.log('[CollaboraViewer] 静默替换模式:自动执行替换:', { searchText, replaceText });
// 重置标志
shouldAutoReplaceRef.current = false;
// 延迟执行,确保 DOM 更新完成
const timer = setTimeout(async () => {
if (!iframeRef.current?.contentWindow) {
console.error('[CollaboraViewer] iframe 未就绪,无法执行替换');
return;
}
try {
// 步骤1:搜索文本(确保文本被选中,会自动跳转到匹配位置)
console.log(`[CollaboraViewer] 步骤1:搜索文本 "${searchText}"`);
unoSearchNext(iframeRef.current.contentWindow, searchText);
// 等待搜索完成
await new Promise(resolve => setTimeout(resolve, 300));
// 步骤2:执行替换(替换后光标保留在当前位置)
console.log(`[CollaboraViewer] 步骤2:替换为 "${replaceText}"`);
unoReplaceCurrent(iframeRef.current.contentWindow, searchText, replaceText);
// 等待替换完成
await new Promise(resolve => setTimeout(resolve, 200));
// 步骤3:自动搜索下一个相同的占位符(如果还有的话)
console.log(`[CollaboraViewer] 步骤3:定位到下一个 "${searchText}"`);
unoSearchNext(iframeRef.current.contentWindow, searchText);
console.log('[CollaboraViewer] ✓ 静默替换完成,已定位到下一个占位符(如有)');
} catch (error) {
console.error('[CollaboraViewer] 静默替换失败:', error);
}
}, 300);
return () => clearTimeout(timer);
}
}, [searchText, replaceText, isDocumentLoaded]); // eslint-disable-line react-hooks/exhaustive-deps
// 加载中状态
if (loading) {
return (
<div className="flex justify-center items-center h-full min-h-[600px]">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
<p className="mt-4 text-gray-600">...</p>
</div>
</div>
);
}
// 错误状态
if (error || !config) {
return (
<div className="flex justify-center items-center h-full min-h-[600px]">
<div className="text-center text-red-500">
<i className="ri-error-warning-line text-4xl mb-2"></i>
<p className="text-lg">{error}</p>
{/* <p className="text-sm text-gray-500 mt-2">请刷新页面重试或联系管理员</p> */}
</div>
</div>
);
}
// 发送 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 (!highlightTextInput || highlightTextInput.trim() === '') {
setHighlightResult('请输入要高亮的文本');
return;
}
try {
// 解析页码 (可选)
const page = highlightPage && highlightPage.trim() !== ''
? parseInt(highlightPage.trim(), 10)
: undefined;
// 验证页码
if (page !== undefined && (isNaN(page) || page < 1)) {
setHighlightResult('页码必须是大于0的整数');
return;
}
const iframeWindow = iframeRef.current.contentWindow;
const textToHighlight = highlightTextInput.trim();
// 先清除旧高亮
await clearHighlights(iframeWindow, {
color: 16776960,
timeout: 5000,
});
await new Promise(resolve => setTimeout(resolve, 100));
// 根据是否有页码选择高亮方式
if (page !== undefined) {
// 使用 Python 脚本(跨页搜索 + 高亮 + 跳转)
const result = await pythonHighlightText(iframeWindow, textToHighlight, {
color: 16776960,
page: page,
});
if (result.success) {
setPreviousHighlightText(textToHighlight);
setHighlightResult(`✓ 已切换高亮: ${textToHighlight} (第${page}页, 共${result.highlightedCount}处)`);
} else {
setHighlightResult(`✗ 高亮失败: ${result.message}`);
}
} else {
// 使用 UNO 命令(当前页面高亮)
unoHighlightText(iframeWindow, textToHighlight, 16776960);
await new Promise(resolve => setTimeout(resolve, 100));
sendUnoCommand(iframeWindow, '.uno:Escape', {});
setPreviousHighlightText(textToHighlight);
setHighlightResult(`✓ 已切换高亮: ${textToHighlight} (当前页)`);
}
} catch (e) {
console.error('切换高亮失败:', e);
setHighlightResult(`切换失败: ${e instanceof Error ? e.message : '未知错误'}`);
}
};
// 滚动到顶部处理函数
const handleScrollToTop = async () => {
if (!iframeRef.current?.contentWindow) {
console.error('iframe 未就绪');
return;
}
try {
await unoScrollToTop(iframeRef.current.contentWindow);
// console.log('[CollaboraViewer] 已滚动到文档顶部');
} catch (e) {
console.error('滚动到顶部失败:', e);
}
};
// 获取页数信息处理函数
const handleGetPageInfo = async () => {
if (!iframeRef.current?.contentWindow) {
// setPageInfoResult('iframe 未就绪');
return;
}
setIsLoadingPageInfo(true);
setPageInfoResult('正在获取页数信息...');
try {
const info: PageInfo = await requestPageInfo(iframeRef.current.contentWindow);
setPageInfoResult(
`✓ 总页数: ${info.totalPages} | 当前页: ${info.currentPage + 1} (索引: ${info.currentPage})`
);
console.log('[CollaboraViewer] 页数信息:', info);
// 3秒后清除提示
setTimeout(() => {
setPageInfoResult(null);
}, 3000);
} catch (e) {
console.error('获取页数信息失败:', e);
setPageInfoResult(`✗ 获取失败: ${e instanceof Error ? e.message : '未知错误'}`);
// 5秒后清除错误提示
setTimeout(() => {
setPageInfoResult(null);
}, 5000);
} finally {
setIsLoadingPageInfo(false);
}
};
// 页面跳转处理函数
const handleGotoPage = async () => {
if (!iframeRef.current?.contentWindow) {
// setGotoPageResult('iframe 未就绪');
return;
}
const pageNumber = parseInt(gotoPageInput.trim(), 10);
if (isNaN(pageNumber) || pageNumber < 1) {
setGotoPageResult('请输入有效的页码 (大于0的整数)');
return;
}
setIsJumping(true);
// setGotoPageResult(`正在跳转到第 ${pageNumber} 页...`);
try {
const response: GotoPageResponse = await customGotoPage(
iframeRef.current.contentWindow,
pageNumber
);
setGotoPageResult(
`✓ 已跳转到第 ${response.pageNumber} 页 (总页数: ${response.totalPages})`
);
// console.log('[CollaboraViewer] 页面跳转成功:', response);
// 3秒后清除提示
setTimeout(() => {
setGotoPageResult(null);
}, 3000);
} catch (e) {
console.error('页面跳转失败:', e);
setGotoPageResult(`✗ 跳转失败: ${e instanceof Error ? e.message : '未知错误'}`);
// 5秒后清除错误提示
setTimeout(() => {
setGotoPageResult(null);
}, 5000);
} finally {
setIsJumping(false);
}
};
// 清除高亮处理函数
const handleClearHighlights = async () => {
if (!iframeRef.current?.contentWindow) {
// setClearHighlightResult('iframe 未就绪');
return;
}
setIsClearing(true);
setClearHighlightResult('正在清除高亮...');
try {
const result = await clearHighlights(iframeRef.current.contentWindow, {
color: 16776960, // 黄色
timeout: 10000,
});
if (result.success) {
setClearHighlightResult(`${result.message}`);
// console.log('[CollaboraViewer] 清除高亮成功:', result);
// 清空之前记录的高亮文本
setPreviousHighlightText(null);
} else {
setClearHighlightResult(`✗ 清除失败: ${result.message}`);
console.error('[CollaboraViewer] 清除高亮失败:', result);
}
// 3秒后清除提示
setTimeout(() => {
setClearHighlightResult(null);
}, 3000);
} catch (e) {
console.error('清除高亮失败:', e);
setClearHighlightResult(`✗ 清除失败: ${e instanceof Error ? e.message : '未知错误'}`);
// 5秒后清除错误提示
setTimeout(() => {
setClearHighlightResult(null);
}, 5000);
} finally {
setIsClearing(false);
}
};
// 搜索下一个处理函数
const handleSearchNext = async () => {
if (!iframeRef.current?.contentWindow) {
// setSearchReplaceResult('iframe 未就绪');
return;
}
if (!searchText.trim()) {
setSearchReplaceResult('请输入搜索文本');
return;
}
try {
const currentSearchText = searchText.trim();
const currentPageNumber = searchReplacePageNumber.trim();
// 判断是否需要跳转页面(搜索文本或页码发生变化)
const needsPageJump =
lastSearchParams.text !== currentSearchText ||
lastSearchParams.page !== currentPageNumber;
// 如果指定了页码且需要跳转,先跳转到该页
if (needsPageJump && currentPageNumber !== '') {
const pageNumber = parseInt(currentPageNumber, 10);
if (isNaN(pageNumber) || pageNumber < 1) {
setSearchReplaceResult('✗ 页码必须是大于0的整数');
return;
}
// setSearchReplaceResult(`正在跳转到第 ${pageNumber} 页...`);
try {
await customGotoPage(iframeRef.current.contentWindow, pageNumber);
// 短暂延迟等待页面渲染
await new Promise(resolve => setTimeout(resolve, 300));
} catch (e) {
console.error('跳转页面失败:', e);
setSearchReplaceResult(`✗ 跳转失败: ${e instanceof Error ? e.message : '未知错误'}`);
return;
}
}
// 执行搜索(如果是页面内循环查找,会自动找到下一个匹配项)
unoSearchNext(iframeRef.current.contentWindow, currentSearchText);
// 更新搜索参数记录
setLastSearchParams({
text: currentSearchText,
page: currentPageNumber
});
// const pageInfo = currentPageNumber !== ''
// ? ` (第${currentPageNumber}页内循环查找)`
// : '';
// const jumpInfo = needsPageJump && currentPageNumber !== '' ? ' [已跳转]' : '';
// setSearchReplaceResult(`✓ 搜索: "${currentSearchText}"${pageInfo}${jumpInfo}`);
// 3秒后清除提示
setTimeout(() => setSearchReplaceResult(null), 3000);
} catch (e) {
console.error('搜索失败:', e);
setSearchReplaceResult(`✗ 搜索失败: ${e instanceof Error ? e.message : '未知错误'}`);
}
};
// 替换处理函数
const handleReplace = async () => {
if (!iframeRef.current?.contentWindow) {
// setSearchReplaceResult('iframe 未就绪');
return;
}
if (!searchText.trim()) {
setSearchReplaceResult('请输入搜索文本');
return;
}
try {
// 如果指定了页码,先跳转到该页
if (searchReplacePageNumber && searchReplacePageNumber.trim() !== '') {
const pageNumber = parseInt(searchReplacePageNumber.trim(), 10);
if (isNaN(pageNumber) || pageNumber < 1) {
setSearchReplaceResult('✗ 页码必须是大于0的整数');
return;
}
// setSearchReplaceResult(`正在跳转到第 ${pageNumber} 页...`);
try {
await customGotoPage(iframeRef.current.contentWindow, pageNumber);
// 短暂延迟等待页面渲染
await new Promise(resolve => setTimeout(resolve, 300));
} catch (e) {
console.error('跳转页面失败:', e);
setSearchReplaceResult(`✗ 跳转失败: ${e instanceof Error ? e.message : '未知错误'}`);
return;
}
}
const pageInfo = searchReplacePageNumber && searchReplacePageNumber.trim() !== ''
? ` (第${searchReplacePageNumber}页)`
: '';
if (replaceAllMode) {
// 替换全部
unoReplaceAll(iframeRef.current.contentWindow, searchText.trim(), replaceText);
setSearchReplaceResult(`✓ 已替换全部: "${searchText.trim()}" → "${replaceText}"${pageInfo}`);
} else {
// 替换当前选中项:先搜索,再替换
console.log('[handleReplace] 开始替换流程:先搜索,再替换');
// 步骤1:先执行搜索,确保文本被选中
unoSearchNext(iframeRef.current.contentWindow, searchText.trim());
// 步骤2:等待搜索完成后执行替换
await new Promise(resolve => setTimeout(resolve, 300));
// 步骤3:执行替换
unoReplaceCurrent(iframeRef.current.contentWindow, searchText.trim(), replaceText);
setSearchReplaceResult(`✓ 已替换: "${searchText.trim()}" → "${replaceText}"${pageInfo}`);
console.log('[handleReplace] 替换完成');
}
// 3秒后清除提示
setTimeout(() => setSearchReplaceResult(null), 3000);
} catch (e) {
console.error('替换失败:', e);
setSearchReplaceResult(`✗ 替换失败: ${e instanceof Error ? e.message : '未知错误'}`);
}
};
// 取消搜索处理函数
const handleCancelSearch = () => {
if (!iframeRef.current?.contentWindow) {
return;
}
try {
unoCancelSearch(iframeRef.current.contentWindow);
// 重置搜索参数记录,下次搜索会重新定位
setLastSearchParams({ text: '', page: '' });
setSearchReplaceResult('✓ 已取消搜索');
setTimeout(() => setSearchReplaceResult(null), 2000);
} catch (e) {
console.error('取消搜索失败:', e);
}
};
// 页面定点替换处理函数
const handleReplaceInPage = async () => {
if (!iframeRef.current?.contentWindow) {
// setReplaceInPageResult('iframe 未就绪');
return;
}
const pageNum = parseInt(replaceInPageNumber.trim(), 10);
if (isNaN(pageNum) || pageNum < 1) {
setReplaceInPageResult('请输入有效的页码 (大于0的整数)');
return;
}
if (!replaceInPageSearch.trim()) {
setReplaceInPageResult('请输入原文');
return;
}
setIsReplacingInPage(true);
setReplaceInPageResult(`正在第 ${pageNum} 页执行替换...`);
try {
const result = await replaceTextInPage(
iframeRef.current.contentWindow,
pageNum,
replaceInPageSearch.trim(),
replaceInPageReplace
);
if (result.success) {
setReplaceInPageResult(`${result.message}`);
} else {
setReplaceInPageResult(`${result.message}`);
}
// 5秒后清除提示
setTimeout(() => setReplaceInPageResult(null), 5000);
} catch (e) {
console.error('页面定点替换失败:', e);
setReplaceInPageResult(`✗ 执行失败: ${e instanceof Error ? e.message : '未知错误'}`);
// 5秒后清除错误提示
setTimeout(() => setReplaceInPageResult(null), 5000);
} finally {
setIsReplacingInPage(false);
}
};
return (
<div className="collabora-viewer relative w-full h-full min-h-[600px]">
{/* UNO 命令测试面板 - 临时隐藏 */}
{/* <div className="absolute top-2 right-2 z-50 bg-white bg-opacity-95 px-3 py-2 rounded shadow-lg flex items-center gap-2 border border-gray-200">
<span className="text-xs text-gray-500 font-medium">UNO:</span>
<input
className="px-2 py-1 border rounded text-sm w-48 font-mono"
value={unoCmd}
onChange={(e) => setUnoCmd(e.target.value)}
placeholder="UNO 命令 (如 .uno:Bold)"
aria-label="UNO 命令"
onKeyPress={(e) => {
if (e.key === 'Enter') {
sendUno();
}
}}
/>
<input
className="px-2 py-1 border rounded text-sm w-64 font-mono"
value={unoArgs}
onChange={(e) => setUnoArgs(e.target.value)}
placeholder='Args (JSON, 如 {"SearchItem.SearchString":{"type":"string","value":"test"}})'
aria-label="UNO Args (JSON)"
onKeyPress={(e) => {
if (e.key === 'Enter') {
sendUno();
}
}}
/>
<button
className="px-3 py-1.5 bg-indigo-600 text-white rounded hover:bg-indigo-700 text-sm font-medium disabled:bg-gray-400"
onClick={sendUno}
disabled={!isDocumentLoaded}
>
发送
</button>
{unoResult && (
<span className={`text-xs ml-1 ${unoResult.startsWith('已发送') ? 'text-green-600' : 'text-red-500'}`}>
{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={highlightTextInput}
onChange={(e) => setHighlightTextInput(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> */}
{/* 清除高亮测试面板 - 临时隐藏 */}
{/* <div className="absolute top-16 left-2 z-50 bg-white bg-opacity-90 px-3 py-2 rounded shadow flex items-center gap-2">
<button
className={`px-4 py-1.5 rounded text-sm font-medium transition-colors ${
isClearing
? 'bg-gray-400 text-white cursor-not-allowed'
: 'bg-red-500 text-white hover:bg-red-600'
}`}
onClick={handleClearHighlights}
disabled={!isDocumentLoaded || isClearing}
>
{isClearing ? '清除中...' : '清除所有高亮'}
</button>
{clearHighlightResult && (
<span className={`text-xs ml-2 ${
clearHighlightResult.startsWith('✓') ? 'text-green-600' :
clearHighlightResult.startsWith('✗') ? 'text-red-600' :
'text-gray-600'
}`}>
{clearHighlightResult}
</span>
)}
</div> */}
{/* 搜索替换测试面板 - 移动到左上角并添加关闭按钮 */}
{showSearchReplacePanel && (
<div className="absolute top-2 left-2 z-50 bg-white bg-opacity-70 px-3 py-2 rounded shadow-lg border border-gray-200">
{/* 标题栏和关闭按钮 */}
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gray-700 font-medium"></span>
<button
onClick={() => setShowSearchReplacePanel(false)}
className="text-gray-400 hover:text-gray-600 transition-colors"
title="关闭面板"
aria-label="关闭搜索替换面板"
>
<i className="ri-close-line text-lg"></i>
</button>
</div>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-500 font-medium w-12">:</span>
<input
className="px-2 py-1 border rounded text-sm w-48"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="输入要搜索的文本"
aria-label="搜索文本"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSearchNext();
}
}}
/>
<span className="text-xs text-gray-500 font-medium w-12">:</span>
<input
className="px-2 py-1 border rounded text-sm w-16"
value={searchReplacePageNumber}
onChange={(e) => setSearchReplacePageNumber(e.target.value)}
placeholder="页码"
aria-label="页码"
type="number"
min="1"
title="可选:指定搜索/替换的页码"
/>
<button
className="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm font-medium disabled:bg-gray-400"
onClick={handleSearchNext}
disabled={!isDocumentLoaded}
>
</button>
<button
className="px-3 py-1 bg-gray-500 text-white rounded hover:bg-gray-600 text-sm font-medium disabled:bg-gray-400"
onClick={handleCancelSearch}
disabled={!isDocumentLoaded}
title="取消搜索选中"
>
</button>
</div>
<div className="flex items-center gap-8 justify-between">
<div className="flex items-center gap-2 flex-1">
<span className="text-xs text-gray-500 font-medium w-12">:</span>
<input
className="px-2 py-1 border rounded text-sm flex-1"
value={replaceText}
onChange={(e) => setReplaceText(e.target.value)}
placeholder="输入替换后的文本"
aria-label="替换文本"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleReplace();
}
}}
/>
<button
className="px-3 py-1 bg-green-500 text-white rounded hover:bg-green-600 text-sm font-medium disabled:bg-gray-400"
onClick={handleReplace}
disabled={!isDocumentLoaded}
>
{replaceAllMode ? '全部确认替换' : '确认替换'}
</button>
{/* <label className="flex items-center gap-1 text-xs text-gray-600 cursor-pointer">
<input
type="checkbox"
checked={replaceAllMode}
onChange={(e) => setReplaceAllMode(e.target.checked)}
className="w-3 h-3"
/>
全部
</label> */}
</div>
<button
className="px-3 py-1 bg-gray-500 text-white rounded hover:bg-gray-600 text-sm font-medium transition-colors"
onClick={() => setShowSearchReplacePanel(false)}
title="关闭搜索替换面板"
aria-label="关闭"
>
</button>
</div>
{searchReplaceResult && (
<div className={`mt-2 text-xs ${searchReplaceResult.startsWith('✓') ? 'text-green-600' :
searchReplaceResult.startsWith('✗') ? 'text-red-600' :
'text-gray-600'
}`}>
{searchReplaceResult}
</div>
)}
</div>
)}
{/* 页面定点替换测试面板 - 临时隐藏 */}
{/* <div className="absolute top-56 left-2 z-50 bg-white bg-opacity-95 px-3 py-2 rounded shadow-lg border border-purple-200">
<div className="text-xs text-purple-600 font-medium mb-2">页面定点替换</div>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-500 font-medium w-12">页码:</span>
<input
className="px-2 py-1 border rounded text-sm w-16"
value={replaceInPageNumber}
onChange={(e) => setReplaceInPageNumber(e.target.value)}
placeholder="如: 12"
aria-label="页码"
type="number"
min="1"
/>
<span className="text-xs text-gray-500 font-medium w-12">原文:</span>
<input
className="px-2 py-1 border rounded text-sm w-32"
value={replaceInPageSearch}
onChange={(e) => setReplaceInPageSearch(e.target.value)}
placeholder="要替换的文本"
aria-label="原文"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 font-medium w-12">新文本:</span>
<input
className="px-2 py-1 border rounded text-sm w-48"
value={replaceInPageReplace}
onChange={(e) => setReplaceInPageReplace(e.target.value)}
placeholder="替换后的文本"
aria-label="新文本"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleReplaceInPage();
}
}}
/>
<button
className={`px-4 py-1 rounded text-sm font-medium transition-colors ${
isReplacingInPage
? 'bg-gray-400 text-white cursor-not-allowed'
: 'bg-purple-500 text-white hover:bg-purple-600'
}`}
onClick={handleReplaceInPage}
disabled={!isDocumentLoaded || isReplacingInPage}
>
{isReplacingInPage ? '执行中...' : '定点替换'}
</button>
</div>
{replaceInPageResult && (
<div className={`mt-2 text-xs ${
replaceInPageResult.startsWith('✓') ? 'text-green-600' :
replaceInPageResult.startsWith('✗') ? 'text-red-600' :
'text-gray-600'
}`}>
{replaceInPageResult}
</div>
)}
</div> */}
{/* 文档加载提示 */}
{!isDocumentLoaded && (
<div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-90 z-10">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
<p className="mt-4 text-gray-600">...</p>
<p className="text-sm text-gray-500 mt-2">{config.fileName}</p>
</div>
</div>
)}
{/* Collabora iframe - tabIndex is needed for keyboard navigation */}
{/* eslint-disable jsx-a11y/no-noninteractive-tabindex */}
<iframe
ref={iframeRef}
src={config.iframeUrl}
className="w-full h-full border-0"
style={{
minHeight: '600px',
height: '100%',
}}
allow="clipboard-read; clipboard-write"
title={`Collabora Online - ${config.fileName}`}
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-modals"
tabIndex={0}
/>
{/* eslint-enable jsx-a11y/no-noninteractive-tabindex */}
</div>
);
});
// 导出类型和 hook
export { useCollaboraUnoCommands };
export type { CollaboraViewerHandle };