temp:临时备份,测试合并兼容性
This commit is contained in:
@@ -146,7 +146,93 @@ The system uses a **port-based multi-client architecture** where:
|
||||
- `MessageModal.tsx` - Confirmation/alert modal system
|
||||
- `Toast.tsx` - Toast notification provider
|
||||
- `LoadingBar.tsx` - Top loading bar for route transitions
|
||||
- `FilePreview.tsx` - PDF/Word document preview
|
||||
- `FilePreview.tsx` - PDF/Word document preview (react-pdf + Collabora integration)
|
||||
|
||||
### Document Preview System
|
||||
|
||||
**Current Implementation** (`app/components/reviews/FilePreview.tsx`):
|
||||
- **PDF files**: Rendered using `react-pdf` library
|
||||
- **DOCX files**: Needs Collabora Online integration (planned)
|
||||
|
||||
**Integration Plan - Collabora Online for DOCX Preview**:
|
||||
|
||||
The FilePreview component should support multiple file types:
|
||||
1. `.pdf` → Use react-pdf (current implementation)
|
||||
2. `.docx` → Use Collabora Online viewer (to be integrated from collabora-test project)
|
||||
|
||||
**Key Pages Using FilePreview**:
|
||||
- `app/routes/reviews.tsx` - Document review page (uses `document.path`)
|
||||
- `app/routes/contract-template.detail.$id.tsx` - Contract template details (uses `template.file_path`)
|
||||
|
||||
**Data Flow for Collabora Integration**:
|
||||
```
|
||||
Page Component (reviews.tsx / contract-template.detail.$id.tsx)
|
||||
↓ passes fileContent.path
|
||||
FilePreview Component (detects file extension)
|
||||
↓ if .docx
|
||||
CollaboraViewer Component (app/components/collabora/)
|
||||
↓ calls API
|
||||
app/routes/api.collabora.config.tsx (Remix loader)
|
||||
↓ generates JWT + WOPISrc URL
|
||||
↓ returns { iframeUrl, accessToken }
|
||||
CollaboraViewer renders iframe
|
||||
↓ Collabora Online loads document
|
||||
↓ calls WOPI endpoints
|
||||
app/routes/api.collabora.wopi.files.$fileId.tsx (Remix loader/action)
|
||||
↓ CheckFileInfo (GET) / GetFile (GET /contents) / PutFile (POST /contents)
|
||||
↓ interacts with MinIO storage
|
||||
Returns document data to Collabora
|
||||
```
|
||||
|
||||
**Files to Migrate from collabora-test**:
|
||||
- `CollaboraViewer.tsx` - Main viewer component
|
||||
- `hooks.ts` - useCollaboraConfig, useCollaboraUICustomization, useDocumentReady, useCollaboraUnoCommands
|
||||
- `api.ts` - API client for Collabora config
|
||||
- `Uno.ts` - LibreOffice UNO commands wrapper
|
||||
- `CollaboraIframeUI.ts` - UI customization utilities
|
||||
- `types.ts` - TypeScript type definitions
|
||||
|
||||
**Backend API Routes to Create**:
|
||||
1. `app/routes/api.collabora.config.tsx` - Generate Collabora iframe URL and JWT token
|
||||
2. `app/routes/api.collabora.wopi.files.$fileId.tsx` - WOPI protocol implementation (CheckFileInfo, GetFile, PutFile)
|
||||
|
||||
**Environment Variables Needed**:
|
||||
```bash
|
||||
# Collabora Online server URL
|
||||
COLLABORA_URL=http://10.79.97.17:9980
|
||||
|
||||
# Application base URL (must be accessible from Collabora server)
|
||||
APP_URL=http://10.79.97.17:51703
|
||||
|
||||
# JWT secret for WOPI token signing (reuse existing JWT_SECRET)
|
||||
```
|
||||
|
||||
**Security Considerations**:
|
||||
- JWT token must include `fileId` for WOPI endpoint validation
|
||||
- File path sanitization to prevent directory traversal attacks
|
||||
- CORS configuration for Collabora server to access WOPI endpoints
|
||||
- WOPI CheckFileInfo should return pure JSON (not wrapped in API response format)
|
||||
|
||||
**File Type Detection in FilePreview**:
|
||||
```typescript
|
||||
const fileExtension = fileContent.path.split('.').pop()?.toLowerCase();
|
||||
|
||||
if (fileExtension === 'pdf') {
|
||||
// Use react-pdf
|
||||
return <PDFViewer />;
|
||||
} else if (fileExtension === 'docx') {
|
||||
// Use Collabora
|
||||
return <CollaboraViewer fileId={fileContent.path} mode="view" />;
|
||||
} else {
|
||||
// Unsupported format
|
||||
return <UnsupportedFileMessage />;
|
||||
}
|
||||
```
|
||||
|
||||
**Reference Implementation**:
|
||||
- See `collabora-test` workspace for complete working example
|
||||
- Adapt Next.js API routes to Remix loader/action pattern
|
||||
- Convert Next.js `route.ts` GET/POST to Remix `loader()` and `action()` functions
|
||||
|
||||
**RemixIcon Usage**:
|
||||
- Icons are locally hosted in `public/fonts/`
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Collabora Online 文档查看器组件
|
||||
*
|
||||
* 功能:
|
||||
* - 加载 Collabora Online iframe
|
||||
* - 管理文档加载状态
|
||||
* - 提供 UNO 命令接口
|
||||
* - 支持只读和编辑模式
|
||||
*
|
||||
* @encoding UTF-8
|
||||
*/
|
||||
|
||||
import { useRef } from 'react';
|
||||
import type { CollaboraViewerProps } from './types';
|
||||
import { useCollaboraConfig, useDocumentReady, useCollaboraUnoCommands } from './hooks';
|
||||
|
||||
/**
|
||||
* Collabora 文档查看器组件
|
||||
* @param props - 组件属性
|
||||
*/
|
||||
export function CollaboraViewer({
|
||||
fileId,
|
||||
mode = 'view',
|
||||
userId = 'guest',
|
||||
userName = '访客',
|
||||
}: CollaboraViewerProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
// 1. 加载 Collabora 配置
|
||||
const { config, loading, error } = useCollaboraConfig(fileId, mode, userId, userName);
|
||||
|
||||
// 2. 监听文档加载状态
|
||||
const { isDocumentLoaded } = useDocumentReady(iframeRef);
|
||||
|
||||
// 3. UNO 命令封装
|
||||
const unoCommands = useCollaboraUnoCommands(iframeRef);
|
||||
|
||||
// 加载中状态
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="collabora-viewer relative w-full h-full min-h-[600px]">
|
||||
{/* 文档加载提示 */}
|
||||
{!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 */}
|
||||
<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"
|
||||
/>
|
||||
|
||||
{/* 调试信息(开发环境) */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div className="mt-2 p-2 bg-gray-100 text-xs rounded">
|
||||
<div>
|
||||
<strong>文档状态:</strong> {isDocumentLoaded ? '已加载' : '加载中...'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>模式:</strong> {config.mode === 'edit' ? '编辑' : '只读'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>文件:</strong> {config.fileName}
|
||||
</div>
|
||||
<div>
|
||||
<strong>用户:</strong> {userName} ({userId})
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 导出 UNO 命令 hook 供父组件使用(如果需要)
|
||||
export { useCollaboraUnoCommands };
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Collabora Online UNO 命令工具函数
|
||||
*
|
||||
* 职责: 封装 Collabora iframe 的 UNO 命令调用
|
||||
*
|
||||
* @encoding UTF-8
|
||||
*/
|
||||
|
||||
/**
|
||||
* 发送 UNO 命令到 Collabora iframe
|
||||
* @param iframeWindow - iframe 的 contentWindow
|
||||
* @param command - UNO 命令名称,如 '.uno:ExecuteSearch'
|
||||
* @param args - 命令参数
|
||||
*/
|
||||
export function sendUnoCommand(
|
||||
iframeWindow: Window,
|
||||
command: string,
|
||||
args: Record<string, any> = {}
|
||||
): void {
|
||||
const message = {
|
||||
MessageId: 'Send_UNO_Command',
|
||||
SendTime: Date.now(),
|
||||
Values: {
|
||||
Command: command,
|
||||
Args: args,
|
||||
},
|
||||
};
|
||||
|
||||
console.log('[UNO] 发送命令:', command, args);
|
||||
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
|
||||
*
|
||||
* 说明: 发送 Get_State 命令作为"哨兵命令",利用 Collabora 的单线程命令队列机制。
|
||||
* 当收到 Doc_ModifiedStatus 类型的回调时,证明前面队列中的所有命令都已执行完毕。
|
||||
*
|
||||
* 响应格式: { MessageId: 'Doc_ModifiedStatus', Values: {...} }
|
||||
*/
|
||||
export function unoGetState(iframeWindow: Window): void {
|
||||
const message = {
|
||||
MessageId: 'Get_State',
|
||||
SendTime: Date.now(),
|
||||
Values: {
|
||||
CommandName: '.uno:ModifiedStatus',
|
||||
},
|
||||
};
|
||||
|
||||
console.log('[UNO] 发送 Get_State (.uno:ModifiedStatus) - 等待命令队列执行完成');
|
||||
iframeWindow.postMessage(JSON.stringify(message), '*');
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Collabora Online 相关的自定义 hooks
|
||||
*
|
||||
* 功能:
|
||||
* - useCollaboraConfig: 加载 Collabora 配置(使用 Remix useFetcher)
|
||||
* - useDocumentReady: 监听文档加载完成
|
||||
* - useCollaboraUnoCommands: UNO 命令封装
|
||||
*
|
||||
* @encoding UTF-8
|
||||
*/
|
||||
|
||||
import { RefObject, useCallback, useEffect, useState, useMemo } from 'react';
|
||||
import { useFetcher } from '@remix-run/react';
|
||||
import { toastService } from '../ui/Toast';
|
||||
import type { CollaboraConfig } from './types';
|
||||
import {
|
||||
unoSearchText,
|
||||
unoReplaceText,
|
||||
unoHighlightText,
|
||||
unoRemoveHighlight,
|
||||
unoEscape,
|
||||
unoScrollToTop,
|
||||
unoSave,
|
||||
} from './Uno';
|
||||
import { COLLABORA_URL } from '~/config/api-config';
|
||||
|
||||
// ==================== 1. 配置加载 ====================
|
||||
|
||||
/**
|
||||
* 加载 Collabora 配置(使用 Remix useFetcher)
|
||||
* @param fileId - 文件路径
|
||||
* @param mode - 模式(view 或 edit)
|
||||
* @param userId - 用户 ID
|
||||
* @param userName - 用户名
|
||||
* @returns 配置、加载状态、错误信息
|
||||
*/
|
||||
export function useCollaboraConfig(
|
||||
fileId: string,
|
||||
mode: 'view' | 'edit',
|
||||
userId: string,
|
||||
userName: string
|
||||
) {
|
||||
const fetcher = useFetcher<CollaboraConfig>();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetcher.state === 'idle' && !fetcher.data) {
|
||||
// 构建查询参数
|
||||
const params = new URLSearchParams({
|
||||
fileId,
|
||||
mode,
|
||||
userId,
|
||||
userName,
|
||||
});
|
||||
|
||||
// 加载配置
|
||||
fetcher.load(`/api/collabora/config?${params}`);
|
||||
}
|
||||
}, [fileId, mode, userId, userName, fetcher]);
|
||||
|
||||
// 检查错误
|
||||
useEffect(() => {
|
||||
if (fetcher.data && 'error' in fetcher.data) {
|
||||
const errorMessage = (fetcher.data as any).error || '加载配置失败';
|
||||
setError(errorMessage);
|
||||
toastService.error(`加载文档配置失败: ${errorMessage}`);
|
||||
}
|
||||
}, [fetcher.data]);
|
||||
|
||||
return {
|
||||
config: fetcher.data && !('error' in fetcher.data) ? fetcher.data : null,
|
||||
loading: fetcher.state === 'loading',
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 2. 文档加载状态监听 ====================
|
||||
|
||||
/**
|
||||
* 监听文档加载完成
|
||||
* @param iframeRef - iframe 引用
|
||||
* @returns 文档加载状态
|
||||
*/
|
||||
export function useDocumentReady(iframeRef: RefObject<HTMLIFrameElement>) {
|
||||
const [isDocumentLoaded, setIsDocumentLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
// 验证消息来源
|
||||
const collaboraOrigin = new URL(COLLABORA_URL).origin;
|
||||
|
||||
if (event.origin !== collaboraOrigin) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const msg = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
||||
|
||||
if (msg.MessageId === 'App_LoadingStatus' && msg.Values?.Status === 'Document_Loaded') {
|
||||
console.log('[DocumentReady] 文档加载完成');
|
||||
setIsDocumentLoaded(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[DocumentReady] 解析消息失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
};
|
||||
}, [iframeRef]);
|
||||
|
||||
return { isDocumentLoaded };
|
||||
}
|
||||
|
||||
// ==================== 3. UNO 命令封装 ====================
|
||||
|
||||
/**
|
||||
* UNO 命令封装(React Hook)
|
||||
* @param iframeRef - iframe 引用
|
||||
* @returns UNO 命令方法集合
|
||||
*/
|
||||
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]);
|
||||
|
||||
/**
|
||||
* 滚动到文档顶部
|
||||
*/
|
||||
const scrollToTop = useCallback(async () => {
|
||||
if (!iframeRef.current?.contentWindow) {
|
||||
console.warn('[UNO] iframe 不可用');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[UNO] 滚动到顶部');
|
||||
unoScrollToTop(iframeRef.current.contentWindow);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}, [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(
|
||||
() => ({
|
||||
searchText,
|
||||
locateText,
|
||||
replaceText,
|
||||
highlightText,
|
||||
removeHighlight,
|
||||
escapeSelection,
|
||||
scrollToTop,
|
||||
saveDocument,
|
||||
}),
|
||||
[
|
||||
searchText,
|
||||
locateText,
|
||||
replaceText,
|
||||
highlightText,
|
||||
removeHighlight,
|
||||
escapeSelection,
|
||||
scrollToTop,
|
||||
saveDocument,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Collabora Online 相关类型定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* Collabora 配置信息
|
||||
*/
|
||||
export interface CollaboraConfig {
|
||||
/** Collabora iframe URL */
|
||||
iframeUrl: string;
|
||||
/** WOPI access token */
|
||||
accessToken: string;
|
||||
/** 文件名 */
|
||||
fileName: string;
|
||||
/** 文件 ID */
|
||||
fileId: string;
|
||||
/** Collabora 服务器 URL */
|
||||
collaboraUrl: string;
|
||||
/** WOPI Src URL */
|
||||
wopiSrc: string;
|
||||
/** 模式 */
|
||||
mode: 'view' | 'edit';
|
||||
}
|
||||
|
||||
/**
|
||||
* CollaboraViewer 组件 Props
|
||||
*/
|
||||
export interface CollaboraViewerProps {
|
||||
/** 文件路径(例如:contracts/test.docx) */
|
||||
fileId: string;
|
||||
/** 查看模式:view=只读,edit=可编辑 */
|
||||
mode?: 'view' | 'edit';
|
||||
/** 用户 ID */
|
||||
userId?: string;
|
||||
/** 用户名称 */
|
||||
userName?: string;
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
import { useState, useEffect, useRef, ChangeEvent } from 'react';
|
||||
import { Document, Page, pdfjs } from 'react-pdf';
|
||||
import { DOCUMENT_URL } from '~/api/axios-client';
|
||||
import { CollaboraViewer } from '~/components/collabora/CollaboraViewer';
|
||||
|
||||
// 设置worker路径为public目录下的worker文件
|
||||
// 使用已经下载的兼容版本 (pdfjs-dist v2.12.313)
|
||||
@@ -73,10 +74,14 @@ interface FilePreviewProps {
|
||||
activeReviewPointResultId: string | null;
|
||||
targetPage?: number; // 新增目标页码参数
|
||||
isStructuredView?: boolean; // 是否显示结构化视图
|
||||
userInfo?: {
|
||||
sub: string;
|
||||
nick_name: string;
|
||||
}; // 用户信息(用于 Collabora)
|
||||
}
|
||||
|
||||
// export function FilePreview({ fileContent, reviewPoints, activeReviewPointResultId, targetPage }: FilePreviewProps) {
|
||||
export function FilePreview({ fileContent, activeReviewPointResultId, targetPage, isStructuredView = false }: FilePreviewProps) {
|
||||
export function FilePreview({ fileContent, activeReviewPointResultId, targetPage, isStructuredView = false, userInfo }: FilePreviewProps) {
|
||||
const [zoomLevel, setZoomLevel] = useState(100);
|
||||
// const [highlightsVisible, setHighlightsVisible] = useState(true);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
@@ -461,8 +466,18 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage
|
||||
}
|
||||
// 普通模式:仅显示PDF
|
||||
return renderPdfContent();
|
||||
} else if (fileExtension === 'docx') {
|
||||
// DOCX文件使用Collabora Online预览
|
||||
return (
|
||||
<CollaboraViewer
|
||||
fileId={real_path}
|
||||
mode="view"
|
||||
userId={userInfo?.sub || 'guest'}
|
||||
userName={userInfo?.nick_name || '访客'}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// 非PDF文件显示不支持消息
|
||||
// 非PDF/DOCX文件显示不支持消息
|
||||
return (
|
||||
<div className="text-gray-500 p-4">
|
||||
<p>暂不支持预览此类型的文件:{fileExtension}</p>
|
||||
|
||||
@@ -11,6 +11,10 @@ interface ApiConfig {
|
||||
documentUrl: string;
|
||||
// 文档上传API URL
|
||||
uploadUrl: string;
|
||||
// Collabora Online 服务器地址
|
||||
collaboraUrl: string;
|
||||
// 应用基础URL(用于 WOPI 回调)
|
||||
appUrl: string;
|
||||
// OAuth2.0配置
|
||||
oauth: {
|
||||
// IDaaS服务器地址
|
||||
@@ -33,9 +37,11 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
|
||||
// 主要
|
||||
// 梅州
|
||||
'51703': {
|
||||
baseUrl: 'http://10.79.97.17:8000',
|
||||
documentUrl: 'http://10.79.97.17:8000/docauditai/',
|
||||
uploadUrl: 'http://10.79.97.17:8000/admin/documents',
|
||||
baseUrl: 'http://127.0.0.1:8073',
|
||||
documentUrl: 'http://127.0.0.1:8073/docauditai/',
|
||||
uploadUrl: 'http://127.0.0.1:8073/admin/documents',
|
||||
collaboraUrl: 'http://172.16.0.81:9980',
|
||||
appUrl: 'http://10.79.97.17:51703',
|
||||
oauth: {
|
||||
redirectUri: 'http://10.79.97.17:51703/callback'
|
||||
}
|
||||
@@ -50,6 +56,8 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
|
||||
baseUrl: 'http://10.79.97.17:8001',
|
||||
documentUrl: 'http://10.79.97.17:8001/docauditai/',
|
||||
uploadUrl: 'http://10.79.97.17:8001/admin/documents',
|
||||
collaboraUrl: 'http://10.79.97.17:9980',
|
||||
appUrl: 'http://10.79.97.17:51704',
|
||||
oauth: {
|
||||
redirectUri: 'http://10.79.97.17:51704/callback'
|
||||
}
|
||||
@@ -60,6 +68,8 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
|
||||
baseUrl: 'http://10.79.97.17:8002',
|
||||
documentUrl: 'http://10.79.97.17:8002/docauditai/',
|
||||
uploadUrl: 'http://10.79.97.17:8002/admin/documents',
|
||||
collaboraUrl: 'http://10.79.97.17:9980',
|
||||
appUrl: 'http://10.79.97.17:51705',
|
||||
oauth: {
|
||||
redirectUri: 'http://10.79.97.17:51705/callback'
|
||||
}
|
||||
@@ -70,6 +80,8 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
|
||||
baseUrl: 'http://10.79.97.17:8003',
|
||||
documentUrl: 'http://10.79.97.17:8003/docauditai/',
|
||||
uploadUrl: 'http://10.79.97.17:8003/admin/documents',
|
||||
collaboraUrl: 'http://10.79.97.17:9980',
|
||||
appUrl: 'http://10.79.97.17:51706',
|
||||
oauth: {
|
||||
redirectUri: 'http://10.79.97.17:51706/callback'
|
||||
}
|
||||
@@ -81,6 +93,8 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
|
||||
baseUrl: 'http://10.79.97.17:8004',
|
||||
documentUrl: 'http://10.79.97.17:8004/docauditai/',
|
||||
uploadUrl: 'http://10.79.97.17:8004/admin/documents',
|
||||
collaboraUrl: 'http://10.79.97.17:9980',
|
||||
appUrl: 'http://10.79.97.17:51707',
|
||||
oauth: {
|
||||
redirectUri: 'http://10.79.97.17:51707/callback'
|
||||
}
|
||||
@@ -90,7 +104,9 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
|
||||
'51708': {
|
||||
baseUrl: 'http://10.79.97.17:8005',
|
||||
documentUrl: 'http://10.79.97.17:8005/docauditai/',
|
||||
uploadUrl: 'http://10.79.97.17:8005/admin/documents'
|
||||
uploadUrl: 'http://10.79.97.17:8005/admin/documents',
|
||||
collaboraUrl: 'http://10.79.97.17:9980',
|
||||
appUrl: 'http://10.79.97.17:51708'
|
||||
},
|
||||
};
|
||||
|
||||
@@ -99,9 +115,11 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
|
||||
const configs: Record<string, ApiConfig> = {
|
||||
// 开发环境
|
||||
development: {
|
||||
baseUrl: 'http://nas.7bm.co:8073', // FastAPI后端(包含/dify代理)
|
||||
documentUrl: 'http://nas.7bm.co:8073/docauditai/',
|
||||
uploadUrl: 'http://nas.7bm.co:8073/admin/documents',
|
||||
baseUrl: 'http://127.0.0.1:8073', // FastAPI后端(包含/dify代理)
|
||||
documentUrl: 'http://127.0.0.1:8073/docauditai/',
|
||||
uploadUrl: 'http://127.0.0.1:8073/admin/documents',
|
||||
collaboraUrl: 'http://172.16.0.81:9980',
|
||||
appUrl: 'http://10.79.97.17:51703',
|
||||
oauth: {
|
||||
serverUrl: 'http://10.79.112.85', // IDaaS服务器地址
|
||||
clientId: 'none',
|
||||
@@ -116,6 +134,8 @@ const configs: Record<string, ApiConfig> = {
|
||||
baseUrl: 'http://nas.7bm.co:8873', // FastAPI后端(包含/dify代理)
|
||||
documentUrl: 'http://nas.7bm.co:8873/docauditai/',
|
||||
uploadUrl: 'http://nas.7bm.co:8873/admin/documents',
|
||||
collaboraUrl: 'http://10.79.97.17:9980',
|
||||
appUrl: 'http://10.79.97.17:51703',
|
||||
oauth: {
|
||||
serverUrl: 'http://10.79.112.85', // IDaaS服务器地址
|
||||
clientId: '54d2a619fe5c81ae1250434c441fccccqMtKwh7H4fO',
|
||||
@@ -132,6 +152,8 @@ const configs: Record<string, ApiConfig> = {
|
||||
documentUrl: 'http://10.76.244.156:9000/docauditai/',
|
||||
// 文件上传
|
||||
uploadUrl: 'http://10.79.97.17:8000/admin/documents',
|
||||
collaboraUrl: 'http://10.79.97.17:9980',
|
||||
appUrl: 'http://10.79.97.17:51703',
|
||||
oauth: {
|
||||
clientId: '54d2a619fe5c81ae1250434c441fccccqMtKwh7H4fO',
|
||||
serverUrl: 'http://10.79.112.85', // IDaaS服务器地址(测试)
|
||||
@@ -149,6 +171,8 @@ const configs: Record<string, ApiConfig> = {
|
||||
baseUrl: 'http://172.16.0.119:9000/admin', // FastAPI后端(包含/dify代理)
|
||||
documentUrl: 'http://nas.7bm.co:9000/docauditai/',
|
||||
uploadUrl: 'http://172.16.0.119:8000/admin/documents/upload',
|
||||
collaboraUrl: 'http://10.79.97.17:9980',
|
||||
appUrl: 'http://172.16.0.119:3000',
|
||||
oauth: {
|
||||
serverUrl: 'http://10.79.112.85', // IDaaS服务器地址
|
||||
clientId: 'none', // 需要替换为实际的Client ID
|
||||
@@ -306,8 +330,8 @@ const getCurrentConfig = (): ApiConfig => {
|
||||
|
||||
// 只有在明确设置了环境变量的情况下才覆盖配置
|
||||
const hasEnvOverrides = process.env.NEXT_PUBLIC_API_BASE_URL ||
|
||||
process.env.NEXT_PUBLIC_DOCUMENT_URL ||
|
||||
process.env.NEXT_PUBLIC_UPLOAD_URL;
|
||||
process.env.NEXT_PUBLIC_DOCUMENT_URL ||
|
||||
process.env.NEXT_PUBLIC_UPLOAD_URL;
|
||||
|
||||
if (hasEnvOverrides) {
|
||||
console.log('🔧 检测到环境变量覆盖,使用环境变量配置');
|
||||
@@ -326,6 +350,8 @@ export const {
|
||||
baseUrl: API_BASE_URL,
|
||||
documentUrl: DOCUMENT_URL,
|
||||
uploadUrl: UPLOAD_URL,
|
||||
collaboraUrl: COLLABORA_URL,
|
||||
appUrl: APP_URL,
|
||||
oauth: OAUTH_CONFIG
|
||||
} = apiConfig;
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Collabora Online 配置生成服务
|
||||
*
|
||||
* 职责:
|
||||
* - 生成 Collabora iframe URL
|
||||
* - 生成 WOPI access token
|
||||
* - 构建完整的 Collabora 配置
|
||||
*
|
||||
* @encoding UTF-8
|
||||
*/
|
||||
|
||||
import { COLLABORA_URL, APP_URL } from '~/config/api-config';
|
||||
import { WopiService } from './wopi.server';
|
||||
import type { CollaboraConfig } from '~/components/collabora/types';
|
||||
|
||||
/**
|
||||
* Collabora 配置生成参数
|
||||
*/
|
||||
export interface GenerateConfigParams {
|
||||
fileId: string;
|
||||
mode: 'view' | 'edit';
|
||||
userId: string;
|
||||
userName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Collabora 配置
|
||||
* @param params - 配置参数
|
||||
* @returns Collabora 配置对象
|
||||
*/
|
||||
export async function generateCollaboraConfig(
|
||||
params: GenerateConfigParams
|
||||
): Promise<CollaboraConfig> {
|
||||
const { fileId, mode, userId, userName } = params;
|
||||
|
||||
// 创建 WOPI 服务实例
|
||||
const wopiService = new WopiService();
|
||||
|
||||
// 生成 WOPI access token(2 小时有效期)
|
||||
const accessToken = wopiService.generateAccessToken(
|
||||
{
|
||||
fileId,
|
||||
mode,
|
||||
userId,
|
||||
userName,
|
||||
},
|
||||
7200 // 2 小时
|
||||
);
|
||||
|
||||
// 构建 WOPI Src URL
|
||||
const wopiSrc = `${APP_URL}/api/collabora/wopi/files/${encodeURIComponent(fileId)}`;
|
||||
|
||||
// 构建 Collabora iframe URL
|
||||
const iframeUrl = buildCollaboraIframeUrl({
|
||||
collaboraUrl: COLLABORA_URL,
|
||||
wopiSrc,
|
||||
accessToken,
|
||||
mode,
|
||||
});
|
||||
|
||||
// 提取文件名
|
||||
const fileName = fileId.split('/').pop() || 'document.docx';
|
||||
|
||||
return {
|
||||
iframeUrl,
|
||||
accessToken,
|
||||
fileName,
|
||||
fileId,
|
||||
collaboraUrl: COLLABORA_URL,
|
||||
wopiSrc,
|
||||
mode,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 Collabora iframe URL
|
||||
* @param params - URL 构建参数
|
||||
* @returns Collabora iframe URL
|
||||
*/
|
||||
function buildCollaboraIframeUrl(params: {
|
||||
collaboraUrl: string;
|
||||
wopiSrc: string;
|
||||
accessToken: string;
|
||||
mode: 'view' | 'edit';
|
||||
}): string {
|
||||
const { collaboraUrl, wopiSrc, accessToken, mode } = params;
|
||||
|
||||
// Collabora iframe 基础 URL
|
||||
// fa80579 是 Collabora 的版本号标识,实际部署时可能需要调整
|
||||
const baseUrl = `${collaboraUrl}/browser/fa80579/cool.html`;
|
||||
|
||||
const url = new URL(baseUrl);
|
||||
|
||||
// 设置 WOPI Src
|
||||
url.searchParams.set('WOPISrc', wopiSrc);
|
||||
|
||||
// 设置 access token
|
||||
url.searchParams.set('access_token', accessToken);
|
||||
|
||||
// 设置 token 过期时间(毫秒)
|
||||
url.searchParams.set('access_token_ttl', '7200000'); // 2 小时
|
||||
|
||||
// UI 定制参数
|
||||
const uiDefaults = [
|
||||
'UIMode=compact', // 紧凑模式
|
||||
'TextRuler=false', // 隐藏标尺
|
||||
'TextStatusbar=false', // 隐藏状态栏
|
||||
'TextSidebar=false', // 隐藏侧边栏
|
||||
'SavedUIState=false', // 不保存 UI 状态
|
||||
].join(';');
|
||||
url.searchParams.set('ui_defaults', uiDefaults);
|
||||
|
||||
// 其他 UI 参数
|
||||
url.searchParams.set('closebutton', '0'); // 隐藏关闭按钮
|
||||
url.searchParams.set('revisionhistory', 'false'); // 禁用修订历史
|
||||
url.searchParams.set('lang', 'zh-CN'); // 设置语言为中文
|
||||
|
||||
// 根据模式设置权限
|
||||
if (mode === 'view') {
|
||||
// 只读模式:通过 URL 参数限制权限
|
||||
url.searchParams.set('permission', 'readonly');
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* WOPI (Web Application Open Platform Interface) 协议服务层
|
||||
*
|
||||
* 职责:
|
||||
* - CheckFileInfo: 返回文件元数据
|
||||
* - GetFile: 返回文件内容
|
||||
* - PutFile: 保存文件内容
|
||||
*
|
||||
* @encoding UTF-8
|
||||
*/
|
||||
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { DOCUMENT_URL } from '~/config/api-config';
|
||||
|
||||
/**
|
||||
* WOPI Access Token Payload
|
||||
*/
|
||||
export interface WopiTokenPayload {
|
||||
fileId: string;
|
||||
mode: 'view' | 'edit';
|
||||
userId: string;
|
||||
userName: string;
|
||||
iat: number; // 签发时间
|
||||
exp: number; // 过期时间
|
||||
}
|
||||
|
||||
/**
|
||||
* WOPI 服务类
|
||||
*/
|
||||
export class WopiService {
|
||||
private readonly jwtSecret: string;
|
||||
|
||||
constructor() {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error('JWT_SECRET environment variable is not set');
|
||||
}
|
||||
this.jwtSecret = secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 WOPI access token
|
||||
* @param params - Token 参数
|
||||
* @param expiresIn - 过期时间(秒),默认 2 小时
|
||||
* @returns JWT token
|
||||
*/
|
||||
generateAccessToken(params: {
|
||||
fileId: string;
|
||||
mode: 'view' | 'edit';
|
||||
userId: string;
|
||||
userName: string;
|
||||
}, expiresIn: number = 7200): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const payload: WopiTokenPayload = {
|
||||
fileId: params.fileId,
|
||||
mode: params.mode,
|
||||
userId: params.userId,
|
||||
userName: params.userName,
|
||||
iat: now,
|
||||
exp: now + expiresIn,
|
||||
};
|
||||
|
||||
return jwt.sign(payload, this.jwtSecret, {
|
||||
algorithm: 'HS256',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 WOPI access token
|
||||
* @param token - JWT token
|
||||
* @param fileId - 文件 ID(用于验证 token 中的 fileId 是否匹配)
|
||||
* @returns Token payload
|
||||
* @throws Error 如果 token 无效
|
||||
*/
|
||||
private verifyAccessToken(token: string, fileId: string): WopiTokenPayload {
|
||||
try {
|
||||
const payload = jwt.verify(token, this.jwtSecret, {
|
||||
algorithms: ['HS256'],
|
||||
}) as WopiTokenPayload;
|
||||
|
||||
// 验证文件 ID 是否匹配
|
||||
if (payload.fileId !== fileId) {
|
||||
throw new Error('Token 文件 ID 不匹配');
|
||||
}
|
||||
|
||||
// 验证过期时间
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp && payload.exp < now) {
|
||||
throw new Error('Token 已过期');
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
console.error('WOPI token 验证失败:', error);
|
||||
throw new Error('Token 无效');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件路径清理(防止目录遍历攻击)
|
||||
* @param fileId - 文件 ID
|
||||
* @returns 清理后的文件 ID
|
||||
*/
|
||||
private sanitizeFileId(fileId: string): string {
|
||||
// 移除 ../ 和绝对路径
|
||||
return fileId.replace(/\.\./g, '').replace(/^\//, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* CheckFileInfo - 返回文件元数据
|
||||
* @param fileId - 文件路径(例如:contracts/test.docx)
|
||||
* @param accessToken - WOPI access token
|
||||
* @returns 文件元数据(WOPI CheckFileInfo 响应)
|
||||
*/
|
||||
async checkFileInfo(fileId: string, accessToken: string) {
|
||||
// 验证 token
|
||||
const tokenData = this.verifyAccessToken(accessToken, fileId);
|
||||
|
||||
// 清理文件路径
|
||||
const sanitizedFileId = this.sanitizeFileId(fileId);
|
||||
|
||||
// 通过 FastAPI 代理获取文件元数据(使用 HEAD 请求)
|
||||
const fileUrl = `${DOCUMENT_URL}${sanitizedFileId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(fileUrl, {
|
||||
method: 'HEAD',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`文件不存在: ${sanitizedFileId}`);
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
const lastModified = response.headers.get('Last-Modified');
|
||||
const fileName = sanitizedFileId.split('/').pop() || 'document.docx';
|
||||
|
||||
// 返回 WOPI CheckFileInfo 响应
|
||||
return {
|
||||
// 基本文件信息
|
||||
BaseFileName: fileName,
|
||||
Size: contentLength ? parseInt(contentLength, 10) : 0,
|
||||
Version: lastModified || Date.now().toString(),
|
||||
|
||||
// 用户信息
|
||||
UserId: tokenData.userId,
|
||||
UserFriendlyName: tokenData.userName,
|
||||
|
||||
// 文件权限
|
||||
UserCanWrite: tokenData.mode === 'edit',
|
||||
UserCanNotWriteRelative: true,
|
||||
|
||||
// Collabora 特定属性
|
||||
EnableOwnerTermination: false,
|
||||
SupportsUpdate: tokenData.mode === 'edit',
|
||||
SupportsLocks: false,
|
||||
|
||||
// UI 隐藏选项
|
||||
HidePrintOption: false,
|
||||
HideSaveOption: false,
|
||||
HideExportOption: false,
|
||||
HideUserList: 'desktop',
|
||||
|
||||
// 功能配置
|
||||
DisableInactiveMessages: true,
|
||||
DisableAutoSave: true,
|
||||
|
||||
// 文件最后修改时间
|
||||
LastModifiedTime: lastModified || new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('CheckFileInfo 失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GetFile - 返回文件内容
|
||||
* @param fileId - 文件路径
|
||||
* @param accessToken - WOPI access token
|
||||
* @returns 文件内容和元数据
|
||||
*/
|
||||
async getFile(fileId: string, accessToken: string) {
|
||||
// 验证 token
|
||||
this.verifyAccessToken(accessToken, fileId);
|
||||
|
||||
// 清理文件路径
|
||||
const sanitizedFileId = this.sanitizeFileId(fileId);
|
||||
|
||||
// 通过 FastAPI 代理获取文件内容
|
||||
const fileUrl = `${DOCUMENT_URL}${sanitizedFileId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(fileUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`获取文件失败: ${sanitizedFileId}`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const contentType = response.headers.get('Content-Type') ||
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||
|
||||
return {
|
||||
buffer,
|
||||
metadata: {
|
||||
contentType,
|
||||
size: buffer.length,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('GetFile 失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PutFile - 保存文件内容
|
||||
* @param fileId - 文件路径
|
||||
* @param accessToken - WOPI access token
|
||||
* @param fileBuffer - 文件内容
|
||||
*/
|
||||
async putFile(fileId: string, accessToken: string, fileBuffer: ArrayBuffer) {
|
||||
// 验证 token
|
||||
const tokenData = this.verifyAccessToken(accessToken, fileId);
|
||||
|
||||
// 检查是否有写入权限
|
||||
if (tokenData.mode !== 'edit') {
|
||||
throw new Error('无写入权限');
|
||||
}
|
||||
|
||||
// 清理文件路径
|
||||
const sanitizedFileId = this.sanitizeFileId(fileId);
|
||||
|
||||
// 通过 FastAPI 代理上传文件
|
||||
const uploadUrl = `${DOCUMENT_URL}${sanitizedFileId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
},
|
||||
body: fileBuffer,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`保存文件失败: ${sanitizedFileId}`);
|
||||
}
|
||||
|
||||
console.log(`PutFile 成功: ${sanitizedFileId}, Size: ${fileBuffer.byteLength} bytes`);
|
||||
} catch (error) {
|
||||
console.error('PutFile 失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Collabora 配置生成 API 路由
|
||||
*
|
||||
* 功能:
|
||||
* - 生成 Collabora iframe URL
|
||||
* - 生成 WOPI access token
|
||||
* - 返回完整的 Collabora 配置
|
||||
*
|
||||
* @encoding UTF-8
|
||||
*/
|
||||
|
||||
import { type LoaderFunctionArgs, json } from '@remix-run/node';
|
||||
import { getUserSession } from '~/api/login/auth.server';
|
||||
import { generateCollaboraConfig } from '~/lib/collabora/config.server';
|
||||
|
||||
/**
|
||||
* GET /api/collabora/config
|
||||
*
|
||||
* 查询参数:
|
||||
* - fileId: 文件路径(例如:contracts/test.docx)
|
||||
* - mode: 模式(view 或 edit),默认 view
|
||||
* - userId: 用户 ID(可选,从 session 获取)
|
||||
* - userName: 用户名(可选,从 session 获取)
|
||||
*/
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
try {
|
||||
// 获取用户会话信息
|
||||
const { userInfo } = await getUserSession(request);
|
||||
|
||||
// 解析查询参数
|
||||
const url = new URL(request.url);
|
||||
const fileId = url.searchParams.get('fileId');
|
||||
const mode = (url.searchParams.get('mode') || 'view') as 'view' | 'edit';
|
||||
const userId = url.searchParams.get('userId') || userInfo?.sub || 'guest';
|
||||
const userName = url.searchParams.get('userName') || userInfo?.nick_name || '访客';
|
||||
|
||||
// 验证必需参数
|
||||
if (!fileId) {
|
||||
return json(
|
||||
{ error: '文件路径不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 生成 Collabora 配置
|
||||
const config = await generateCollaboraConfig({
|
||||
fileId,
|
||||
mode,
|
||||
userId,
|
||||
userName,
|
||||
});
|
||||
|
||||
return json(config);
|
||||
} catch (error) {
|
||||
console.error('生成 Collabora 配置失败:', error);
|
||||
return json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : '生成配置失败',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* WOPI 协议 API 路由
|
||||
*
|
||||
* 功能:
|
||||
* - CheckFileInfo: GET /api/collabora/wopi/files/{fileId}
|
||||
* - GetFile: GET /api/collabora/wopi/files/{fileId}/contents
|
||||
* - PutFile: POST /api/collabora/wopi/files/{fileId}/contents
|
||||
*
|
||||
* @encoding UTF-8
|
||||
*/
|
||||
|
||||
import { type LoaderFunctionArgs, type ActionFunctionArgs } from '@remix-run/node';
|
||||
import { WopiService } from '~/lib/collabora/wopi.server';
|
||||
|
||||
const wopiService = new WopiService();
|
||||
|
||||
/**
|
||||
* GET 请求处理
|
||||
* - 无 /contents 后缀 → CheckFileInfo
|
||||
* - 有 /contents 后缀 → GetFile
|
||||
*/
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const accessToken = url.searchParams.get('access_token');
|
||||
|
||||
if (!accessToken) {
|
||||
return new Response('访问令牌缺失', { status: 401 });
|
||||
}
|
||||
|
||||
// 获取文件 ID
|
||||
const fileId = params.fileId || '';
|
||||
|
||||
// 判断是否是 GetFile 请求(路径以 /contents 结尾)
|
||||
const isContentsRequest = url.pathname.endsWith('/contents');
|
||||
|
||||
if (isContentsRequest) {
|
||||
// GetFile: 返回文件内容
|
||||
const { buffer, metadata } = await wopiService.getFile(fileId, accessToken);
|
||||
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
'Content-Type': metadata.contentType,
|
||||
'Content-Length': metadata.size.toString(),
|
||||
'Content-Disposition': 'inline',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// CheckFileInfo: 返回文件元数据
|
||||
const checkFileInfo = await wopiService.checkFileInfo(fileId, accessToken);
|
||||
|
||||
// 注意:CheckFileInfo 必须返回纯 JSON,不能使用 Result.success() 包装
|
||||
return Response.json(checkFileInfo);
|
||||
} catch (error) {
|
||||
console.error('WOPI GET 失败:', error);
|
||||
return new Response(
|
||||
error instanceof Error ? error.message : 'Internal server error',
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 请求处理
|
||||
* - PutFile: 保存文件内容
|
||||
*/
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const accessToken = url.searchParams.get('access_token');
|
||||
|
||||
if (!accessToken) {
|
||||
return new Response('访问令牌缺失', { status: 401 });
|
||||
}
|
||||
|
||||
const fileId = params.fileId || '';
|
||||
|
||||
// 判断是否是 PutFile 请求(路径以 /contents 结尾)
|
||||
const isContentsRequest = url.pathname.endsWith('/contents');
|
||||
|
||||
if (!isContentsRequest) {
|
||||
return new Response('PutFile 必须使用 /contents 路径', { status: 400 });
|
||||
}
|
||||
|
||||
// PutFile: 保存文件
|
||||
const fileBuffer = await request.arrayBuffer();
|
||||
await wopiService.putFile(fileId, accessToken, fileBuffer);
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error('WOPI POST 失败:', error);
|
||||
return new Response(
|
||||
error instanceof Error ? error.message : 'Internal server error',
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -150,11 +150,13 @@ export default function ContractTemplateDetail() {
|
||||
}; */
|
||||
|
||||
// 创建文件内容对象用于FilePreview组件
|
||||
const fileContent = template.pdf_file_path ? {
|
||||
// 优先使用原始文件路径(支持docx),如果没有则使用pdf_file_path
|
||||
const previewPath = template.file_path || template.pdf_file_path;
|
||||
const fileContent = previewPath ? {
|
||||
title: template.title,
|
||||
contractNumber: template.template_code,
|
||||
// 使用pdf_file_path字段
|
||||
path: template.pdf_file_path,
|
||||
// 使用file_path以支持多种格式(docx/pdf)
|
||||
path: previewPath,
|
||||
parties: {
|
||||
partyA: {
|
||||
name: '',
|
||||
|
||||
Reference in New Issue
Block a user