Merge branch 'PingChuan' into shiy-login

# Conflicts:
#	app/config/api-config.ts
fix: 1. 修复无法加载数据的问题:没有从入口页中进来会缺少数据。
2. 加强后端接口关于token的校验错误和权限校验错误的管理。

feat: 1. 对接后端的数据看板的接口。
2. 将系统设置单独抽出来作为管理员的固定一个入口。
This commit is contained in:
2025-11-22 15:57:22 +08:00
27 changed files with 1972 additions and 643 deletions
@@ -0,0 +1,94 @@
/**
* 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"
/>
</div>
);
}
// 导出 UNO 命令 hook 供父组件使用(如果需要)
export { useCollaboraUnoCommands };
+168
View File
@@ -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), '*');
}
+284
View File
@@ -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,
]
);
}
+37
View File
@@ -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;
}
+31 -8
View File
@@ -90,12 +90,16 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid
fetchUserRoutes();
}, [userRole, frontendJWT, navigate]);
// 从 sessionStorage 读取当前选中的模块名称和图片路径
// 🔑 检查是否处于系统设置模式
const [isSettingsMode, setIsSettingsMode] = useState<boolean>(false);
// 从 sessionStorage 读取当前选中的模块名称和图片路径,以及系统设置模式标志
useEffect(() => {
if (typeof window !== 'undefined') {
try {
const moduleName = sessionStorage.getItem('selectedModuleName');
const modulePicPath = sessionStorage.getItem('selectedModulePicPath');
const settingsMode = sessionStorage.getItem('settingsMode');
if (moduleName) {
setSelectedModuleName(moduleName);
@@ -106,6 +110,14 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid
setSelectedModulePicPath(modulePicPath);
console.log('🖼️ [Sidebar] 模块图片路径:', modulePicPath);
}
// 🔑 检查是否处于系统设置模式
if (settingsMode === 'true') {
setIsSettingsMode(true);
console.log('⚙️ [Sidebar] 进入系统设置模式');
} else {
setIsSettingsMode(false);
}
} catch (error) {
console.error('❌ [Sidebar] 读取 sessionStorage 失败:', error);
}
@@ -154,19 +166,30 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid
// console.log('子菜单点击:', child.title, '路径:', child.path);
};
const isPort51707 = typeof window !== 'undefined' && window.location.port === '51707'
// const isPort51707 = typeof window !== 'undefined' && window.location.port === '51707'
// 处理菜单项:清理子菜单结构
const processedMenuItems: MenuItem[] = menuItems.filter(item =>{
// console.log('菜单项:', item.title, 'Icon:', item.icon)
// 如果是省局访问
if(isPort51707){
if (selectedModuleName === '智慧法务大模型'){
return item.path && item.path.startsWith('/chat-with-llm')
}
return item.path && item.path.startsWith('/cross-checking')
// 🔑 优先检查:如果处于系统设置模式,只显示 /settings 及其子路由
if (isSettingsMode) {
return item.path === '/settings' || item.path?.startsWith('/settings/');
}
// 🔑 重要:非系统设置模式下,隐藏所有 /settings 相关菜单
if (item.path === '/settings' || item.path?.startsWith('/settings/')) {
return false;
}
// 如果是省局访问
// if(isPort51707){
// if (selectedModuleName === '智慧法务大模型'){
// return item.path && item.path.startsWith('/chat-with-llm')
// }
// return item.path && item.path.startsWith('/cross-checking')
// }
// 🔑 如果选择了"智慧法务大模型",只显示 /chat-with-llm 相关菜单
if (selectedModuleName === '智慧法务大模型') {
return item.path === '/chat-with-llm' || item.path?.startsWith('/chat-with-llm/');
+17 -2
View File
@@ -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>