Merge branch 'PingChuan' into shiy-login
# Conflicts: # app/config/api-config.ts fix: 1. 修复无法加载数据的问题:没有从入口页中进来会缺少数据。 2. 加强后端接口关于token的校验错误和权限校验错误的管理。 feat: 1. 对接后端的数据看板的接口。 2. 将系统设置单独抽出来作为管理员的固定一个入口。
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
|
- `MessageModal.tsx` - Confirmation/alert modal system
|
||||||
- `Toast.tsx` - Toast notification provider
|
- `Toast.tsx` - Toast notification provider
|
||||||
- `LoadingBar.tsx` - Top loading bar for route transitions
|
- `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**:
|
**RemixIcon Usage**:
|
||||||
- Icons are locally hosted in `public/fonts/`
|
- Icons are locally hosted in `public/fonts/`
|
||||||
|
|||||||
@@ -493,11 +493,11 @@ const FALLBACK_MENU_DATA: Record<string, MenuItem[]> = {
|
|||||||
*/
|
*/
|
||||||
export async function getUserRoutesByRole(roleKey: string, jwt?: string, includeHidden: boolean = false): Promise<{ success: boolean; data?: MenuItem[]; error?: string; shouldRedirectToHome?: boolean }> {
|
export async function getUserRoutesByRole(roleKey: string, jwt?: string, includeHidden: boolean = false): Promise<{ success: boolean; data?: MenuItem[]; error?: string; shouldRedirectToHome?: boolean }> {
|
||||||
try {
|
try {
|
||||||
// console.log(`🔍 [User Routes] 获取用户路由,角色: ${roleKey}`);
|
// console.log(`🔍 [User Routes] 获取用户路由,角色: ${roleKey}, JWT前20字符: ${jwt?.substring(0, 20)}`);
|
||||||
|
|
||||||
if (!jwt) {
|
if (!jwt) {
|
||||||
console.error('❌ [User Routes] JWT token 未提供');
|
console.error('❌ [User Routes] JWT token 未提供');
|
||||||
toastService.error("认证信息缺失,请重新登录");
|
// 不显示 toast,让 root loader 处理重定向
|
||||||
return { success: false, error: "JWT token 未提供", shouldRedirectToHome: true };
|
return { success: false, error: "JWT token 未提供", shouldRedirectToHome: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,15 +519,34 @@ export async function getUserRoutesByRole(roleKey: string, jwt?: string, include
|
|||||||
// 检查响应是否成功
|
// 检查响应是否成功
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
console.error('❌ [User Routes] API 请求失败:', response.error);
|
console.error('❌ [User Routes] API 请求失败:', response.error);
|
||||||
|
// 🔑 如果是令牌过期错误,标记需要重定向到登录页
|
||||||
|
const isTokenExpired = response.error.includes('令牌已过期') ||
|
||||||
|
response.error.includes('令牌') ||
|
||||||
|
response.error.includes('token') ||
|
||||||
|
response.error.includes('expired') ||
|
||||||
|
response.error.includes('认证') ||
|
||||||
|
response.error.includes('401');
|
||||||
|
|
||||||
|
console.log('🔍 [User Routes] 错误检测:', {
|
||||||
|
error: response.error,
|
||||||
|
isTokenExpired,
|
||||||
|
willRedirect: isTokenExpired
|
||||||
|
});
|
||||||
|
|
||||||
|
// 只在客户端显示toast(服务端调用时跳过)
|
||||||
|
if (!isTokenExpired && typeof window !== 'undefined') {
|
||||||
toastService.error(response.error);
|
toastService.error(response.error);
|
||||||
return { success: false, error: response.error, shouldRedirectToHome: true };
|
}
|
||||||
|
return { success: false, error: response.error, shouldRedirectToHome: isTokenExpired };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查响应数据
|
// 检查响应数据
|
||||||
if (!response.data) {
|
if (!response.data) {
|
||||||
console.error('❌ [User Routes] 后端未返回数据');
|
console.error('❌ [User Routes] 后端未返回数据');
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
toastService.error("获取路由数据失败");
|
toastService.error("获取路由数据失败");
|
||||||
return { success: false, error: "后端未返回数据", shouldRedirectToHome: true };
|
}
|
||||||
|
return { success: false, error: "后端未返回数据", shouldRedirectToHome: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const backendResponse = response.data;
|
const backendResponse = response.data;
|
||||||
@@ -535,23 +554,45 @@ export async function getUserRoutesByRole(roleKey: string, jwt?: string, include
|
|||||||
// 检查业务状态码(后端使用 code: 0 表示成功)
|
// 检查业务状态码(后端使用 code: 0 表示成功)
|
||||||
if (backendResponse.code !== 0 && backendResponse.code !== 200) {
|
if (backendResponse.code !== 0 && backendResponse.code !== 200) {
|
||||||
console.error(`❌ [User Routes] 后端返回错误: ${backendResponse.msg}`);
|
console.error(`❌ [User Routes] 后端返回错误: ${backendResponse.msg}`);
|
||||||
|
// 🔑 如果是令牌过期错误,标记需要重定向到登录页
|
||||||
|
const isTokenExpired = backendResponse.msg?.includes('令牌已过期') ||
|
||||||
|
backendResponse.msg?.includes('令牌') ||
|
||||||
|
backendResponse.msg?.includes('token') ||
|
||||||
|
backendResponse.msg?.includes('expired') ||
|
||||||
|
backendResponse.msg?.includes('认证') ||
|
||||||
|
backendResponse.msg?.includes('401');
|
||||||
|
|
||||||
|
console.log('🔍 [User Routes] 业务错误检测:', {
|
||||||
|
msg: backendResponse.msg,
|
||||||
|
code: backendResponse.code,
|
||||||
|
isTokenExpired,
|
||||||
|
willRedirect: isTokenExpired
|
||||||
|
});
|
||||||
|
|
||||||
|
// 只在客户端显示toast
|
||||||
|
if (!isTokenExpired && typeof window !== 'undefined') {
|
||||||
toastService.error(backendResponse.msg || "获取路由权限失败");
|
toastService.error(backendResponse.msg || "获取路由权限失败");
|
||||||
return { success: false, error: backendResponse.msg || "获取路由权限失败", shouldRedirectToHome: true };
|
}
|
||||||
|
return { success: false, error: backendResponse.msg || "获取路由权限失败", shouldRedirectToHome: isTokenExpired };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查数据完整性
|
// 检查数据完整性
|
||||||
if (!backendResponse.data || !Array.isArray(backendResponse.data.routes)) {
|
if (!backendResponse.data || !Array.isArray(backendResponse.data.routes)) {
|
||||||
console.error('❌ [User Routes] 后端未返回路由数据');
|
console.error('❌ [User Routes] 后端未返回路由数据');
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
toastService.error("未获取到路由权限,请联系管理员配置");
|
toastService.error("未获取到路由权限,请联系管理员配置");
|
||||||
return { success: false, error: "后端未返回路由数据", shouldRedirectToHome: true };
|
}
|
||||||
|
return { success: false, error: "后端未返回路由数据", shouldRedirectToHome: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const routes = backendResponse.data.routes;
|
const routes = backendResponse.data.routes;
|
||||||
|
|
||||||
if (routes.length === 0) {
|
if (routes.length === 0) {
|
||||||
console.log(`⚠️ [User Routes] 用户没有分配任何路由权限`);
|
console.log(`⚠️ [User Routes] 用户没有分配任何路由权限`);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
toastService.error("您的角色没有分配任何路由权限,请联系管理员配置");
|
toastService.error("您的角色没有分配任何路由权限,请联系管理员配置");
|
||||||
return { success: false, error: "用户没有分配任何路由权限", shouldRedirectToHome: true };
|
}
|
||||||
|
return { success: false, error: "用户没有分配任何路由权限", shouldRedirectToHome: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log('🔍 [User Routes] 后端返回的原始路由数据:', JSON.stringify(routes, null, 2));
|
// console.log('🔍 [User Routes] 后端返回的原始路由数据:', JSON.stringify(routes, null, 2));
|
||||||
@@ -568,11 +609,31 @@ export async function getUserRoutesByRole(roleKey: string, jwt?: string, include
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ [User Routes] 获取用户路由时发生错误:", error);
|
console.error("❌ [User Routes] 获取用户路由时发生错误:", error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
// 🔑 如果是认证相关错误,标记需要重定向到登录页
|
||||||
|
const isAuthError = errorMessage.includes('令牌') ||
|
||||||
|
errorMessage.includes('token') ||
|
||||||
|
errorMessage.includes('expired') ||
|
||||||
|
errorMessage.includes('认证') ||
|
||||||
|
errorMessage.includes('401') ||
|
||||||
|
errorMessage.includes('403');
|
||||||
|
|
||||||
|
console.log('🔍 [User Routes] 异常错误检测:', {
|
||||||
|
errorMessage,
|
||||||
|
isAuthError,
|
||||||
|
willRedirect: isAuthError
|
||||||
|
});
|
||||||
|
|
||||||
|
// 只在客户端显示toast
|
||||||
|
if (!isAuthError && typeof window !== 'undefined') {
|
||||||
toastService.error("获取用户路由时发生错误,请稍后再试");
|
toastService.error("获取用户路由时发生错误,请稍后再试");
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `获取用户路由失败: ${error instanceof Error ? error.message : String(error)}`,
|
error: `获取用户路由失败: ${errorMessage}`,
|
||||||
shouldRedirectToHome: true
|
shouldRedirectToHome: isAuthError
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-2
@@ -443,9 +443,35 @@ export async function apiRequest<T>(
|
|||||||
// 检查API返回的状态码
|
// 检查API返回的状态码
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (data && typeof data === 'object' && 'code' in data && data.code !== 0) {
|
if (data && typeof data === 'object' && 'code' in data && data.code !== 0) {
|
||||||
console.error(`API请求失败: ${data.message || data.msg || '未知错误'} - ${url}`);
|
const errorMessage = data.message || data.msg || '未知错误';
|
||||||
|
console.error(`API请求失败: ${errorMessage} - ${url}`);
|
||||||
|
|
||||||
|
// 🔑 检测令牌过期错误
|
||||||
|
const isTokenExpired = errorMessage.includes('令牌已过期') ||
|
||||||
|
errorMessage.includes('令牌') ||
|
||||||
|
errorMessage.includes('token') ||
|
||||||
|
errorMessage.includes('expired') ||
|
||||||
|
errorMessage.includes('认证') ||
|
||||||
|
errorMessage.includes('未授权');
|
||||||
|
|
||||||
|
if (isTokenExpired) {
|
||||||
|
console.error('🔑 [API Client] 检测到令牌过期,准备清除会话并重定向...');
|
||||||
|
|
||||||
|
// 只在客户端执行重定向
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
console.error('🔑 [API Client] 客户端环境,清除 localStorage 并重定向到登录页');
|
||||||
|
// 清除所有认证相关数据
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('user_info');
|
||||||
|
sessionStorage.clear();
|
||||||
|
|
||||||
|
// 重定向到登录页
|
||||||
|
window.location.href = '/login?expired=true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
error: data.message || data.msg || '请求失败',
|
error: errorMessage,
|
||||||
status: response.status,
|
status: response.status,
|
||||||
headers: responseHeaders
|
headers: responseHeaders
|
||||||
};
|
};
|
||||||
|
|||||||
+118
-382
@@ -1,6 +1,6 @@
|
|||||||
import { postgrestGet, postgrestPost, type PostgrestParams } from "../postgrest-client";
|
import { postgrestGet, type PostgrestParams } from "../postgrest-client";
|
||||||
import { apiRequest } from "../axios-client";
|
import { apiRequest } from "../axios-client";
|
||||||
import dayjs from 'dayjs';
|
// import dayjs from 'dayjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从不同格式的 API 响应中提取数据
|
* 从不同格式的 API 响应中提取数据
|
||||||
@@ -78,397 +78,107 @@ interface HomeStatistics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过传入的 reviewType 参数构建类型过滤条件
|
* 后端统计接口响应类型(蛇形命名)
|
||||||
* @param reviewType 文档类型
|
|
||||||
* @returns 过滤条件字符串
|
|
||||||
*/
|
*/
|
||||||
function buildTypeFilter(reviewType: string | null): string {
|
interface BackendStatisticsResponse {
|
||||||
let typeFilter = '';
|
today_pending_files: number;
|
||||||
if (reviewType === 'contract') {
|
monthly_reviewed_files: number;
|
||||||
typeFilter = 'type_id.eq.1';
|
monthly_review_growth: {
|
||||||
} else if (reviewType === 'record') {
|
value: number;
|
||||||
typeFilter = '(type_id.eq.2,type_id.eq.3)';
|
is_up: boolean;
|
||||||
}
|
};
|
||||||
return typeFilter;
|
monthly_pass_rate: number;
|
||||||
|
pass_rate_growth: {
|
||||||
|
value: number;
|
||||||
|
is_up: boolean;
|
||||||
|
};
|
||||||
|
issues_detected: number;
|
||||||
|
issues_growth: {
|
||||||
|
value: number;
|
||||||
|
is_up: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取主页数据
|
* 获取主页数据
|
||||||
* @param reviewType 从客户端传入的 reviewType 值
|
* @param reviewType 从客户端传入的 reviewType 值(已废弃,现在从sessionStorage读取)
|
||||||
* @param userId 用户ID
|
* @param userId 用户ID(已废弃,后端通过JWT自动识别)
|
||||||
* @param token JWT token
|
* @param token JWT token
|
||||||
* @returns 主页数据
|
* @returns 主页数据
|
||||||
*/
|
*/
|
||||||
export async function getHomeData(reviewType?: string | null,userId?: string | number, token?: string): Promise<HomeStatistics> {
|
export async function getHomeData(reviewType?: string | null, userId?: string | number, token?: string): Promise<HomeStatistics> {
|
||||||
try {
|
try {
|
||||||
// 获取当前日期和时间相关值
|
// 🔑 从 sessionStorage 获取文档类型IDs
|
||||||
const startOfToday = dayjs().startOf('day').format('YYYY-MM-DD HH:mm:ss');
|
let typeIds: string | null = null;
|
||||||
const startOfThisMonth = dayjs().startOf('month').format('YYYY-MM-DD HH:mm:ss');
|
if (typeof window !== 'undefined') {
|
||||||
const endOfThisMonth = dayjs().endOf('month').format('YYYY-MM-DD HH:mm:ss');
|
const storedTypeIds = sessionStorage.getItem('documentTypeIds');
|
||||||
const startOfLastMonth = dayjs().subtract(1, 'month').startOf('month').format('YYYY-MM-DD HH:mm:ss');
|
if (storedTypeIds) {
|
||||||
const endOfLastMonth = dayjs().subtract(1, 'month').endOf('month').format('YYYY-MM-DD HH:mm:ss');
|
|
||||||
|
|
||||||
// console.log('传入的 reviewType', reviewType);
|
|
||||||
// console.log('传入的 userId', userId);
|
|
||||||
|
|
||||||
// 基于 reviewType 构建类型过滤条件
|
|
||||||
const typeFilter = buildTypeFilter(reviewType || null);
|
|
||||||
// console.log('构建的 typeFilter', typeFilter);
|
|
||||||
|
|
||||||
// 通用API响应处理函数
|
|
||||||
const handleApiResponse = async <T>(
|
|
||||||
apiCall: Promise<{
|
|
||||||
data?: unknown;
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
error?: string;
|
|
||||||
status?: number
|
|
||||||
}>,
|
|
||||||
errorMessage: string,
|
|
||||||
defaultValue: T
|
|
||||||
): Promise<T> => {
|
|
||||||
try {
|
try {
|
||||||
const response = await apiCall;
|
const typeIdsArray = JSON.parse(storedTypeIds) as number[];
|
||||||
if (response.error) {
|
if (Array.isArray(typeIdsArray) && typeIdsArray.length > 0) {
|
||||||
console.error(`${errorMessage}: ${response.error}`);
|
typeIds = typeIdsArray.join(',');
|
||||||
return defaultValue;
|
console.log('📊 [getHomeData] 从 sessionStorage 获取文档类型:', typeIds);
|
||||||
}
|
}
|
||||||
const data = extractApiData<T>(response.data);
|
|
||||||
if (!data) {
|
|
||||||
console.warn(`${errorMessage}: 无法提取有效数据`);
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`${errorMessage}: ${error instanceof Error ? error.message : '未知错误'}`);
|
console.error('❌ [getHomeData] 解析 documentTypeIds 失败:', error);
|
||||||
return defaultValue;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔑 构建请求参数
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
time_range: '30days' // 默认近30天
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. 今日待审核文件 - 获取今天的待审核文件数量 (audit_status = 0 或 2)
|
// 如果有文档类型,添加到参数
|
||||||
const todayPendingParams: PostgrestParams = {
|
if (typeIds) {
|
||||||
select: 'count',
|
params.type_ids = typeIds;
|
||||||
filter: {
|
|
||||||
or: `(audit_status.eq.0,audit_status.eq.2,audit_status.is.null)`,
|
|
||||||
created_at: `gte.${startOfToday}`,
|
|
||||||
is_test_document: `eq.false`,
|
|
||||||
user_id: `eq.${userId}`
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 添加类型过滤条件
|
|
||||||
if (typeFilter) {
|
|
||||||
if (typeFilter.startsWith('(')) {
|
|
||||||
// 确保 filter 已初始化
|
|
||||||
if (!todayPendingParams.filter) {
|
|
||||||
todayPendingParams.filter = {};
|
|
||||||
}
|
|
||||||
todayPendingParams.filter.or = typeFilter + ',' + todayPendingParams.filter.or;
|
|
||||||
} else {
|
|
||||||
const [field, op, value] = typeFilter.split('.');
|
|
||||||
if (!todayPendingParams.filter) {
|
|
||||||
todayPendingParams.filter = {};
|
|
||||||
}
|
|
||||||
todayPendingParams.filter[field] = `${op}.${value}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const todayPendingCount = await handleApiResponse<{ count: number }[]>(
|
console.log('📊 [getHomeData] 请求参数:', params);
|
||||||
postgrestGet('documents', { ...todayPendingParams, token }),
|
|
||||||
'获取今日待审核文件数量失败',
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
const todayPendingFiles = todayPendingCount[0]?.count || 0;
|
|
||||||
|
|
||||||
// 2. 本月已审核文件 - 获取本月已审核文件数量 (audit_status != 0 且 != 2)
|
// 🔑 调用后端统计接口
|
||||||
const thisMonthReviewedParams: PostgrestParams = {
|
const response = await apiRequest<BackendStatisticsResponse>(
|
||||||
select: 'count',
|
'/admin/statistics/home-data',
|
||||||
filter: {
|
{
|
||||||
and: `(audit_status.neq.0,audit_status.neq.2)`,
|
method: 'GET',
|
||||||
upload_time: `gte.${startOfThisMonth}`,
|
headers: token ? {
|
||||||
is_test_document: `eq.false`,
|
'Authorization': `Bearer ${token}`
|
||||||
user_id: `eq.${userId}`
|
} : undefined
|
||||||
}
|
},
|
||||||
};
|
params // 查询参数
|
||||||
|
|
||||||
// 添加类型过滤条件
|
|
||||||
if (typeFilter) {
|
|
||||||
if (typeFilter.startsWith('(')) {
|
|
||||||
thisMonthReviewedParams.or = typeFilter;
|
|
||||||
} else {
|
|
||||||
const [field, op, value] = typeFilter.split('.');
|
|
||||||
if (!thisMonthReviewedParams.filter) {
|
|
||||||
thisMonthReviewedParams.filter = {};
|
|
||||||
}
|
|
||||||
thisMonthReviewedParams.filter[field] = `${op}.${value}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const thisMonthReviewedCount = await handleApiResponse<{ count: number }[]>(
|
|
||||||
postgrestGet('documents', { ...thisMonthReviewedParams, token }),
|
|
||||||
'获取本月已审核文件数量失败',
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
// 本月已审核文件数量
|
|
||||||
const monthlyReviewedFiles = thisMonthReviewedCount[0]?.count || 0;
|
|
||||||
|
|
||||||
// 上月已审核文件
|
|
||||||
const lastMonthReviewedParams: PostgrestParams = {
|
|
||||||
select: 'count',
|
|
||||||
filter: {
|
|
||||||
// or: `(audit_status.eq.1,audit_status.eq.-1)`,
|
|
||||||
and: `(upload_time.gte.${startOfLastMonth},upload_time.lte.${endOfLastMonth},audit_status.neq.0,audit_status.neq.2)`,
|
|
||||||
is_test_document: `eq.false`,
|
|
||||||
user_id: `eq.${userId}`
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 添加类型过滤条件
|
|
||||||
if (typeFilter) {
|
|
||||||
if (typeFilter.startsWith('(')) {
|
|
||||||
// 确保 filter 已初始化
|
|
||||||
if (!lastMonthReviewedParams.filter) {
|
|
||||||
lastMonthReviewedParams.filter = {};
|
|
||||||
}
|
|
||||||
lastMonthReviewedParams.filter.or = typeFilter;
|
|
||||||
} else {
|
|
||||||
const [field, op, value] = typeFilter.split('.');
|
|
||||||
if (!lastMonthReviewedParams.filter) {
|
|
||||||
lastMonthReviewedParams.filter = {};
|
|
||||||
}
|
|
||||||
lastMonthReviewedParams.filter[field] = `${op}.${value}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastMonthReviewedCount = await handleApiResponse<{ count: number }[]>(
|
|
||||||
postgrestGet('documents', { ...lastMonthReviewedParams, token }),
|
|
||||||
'获取上月已审核文件数量失败',
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
// 上月已审核文件数量
|
|
||||||
const lastMonthReviewed = lastMonthReviewedCount[0]?.count || 0;
|
|
||||||
// console.log('上月已审核文件查询参数', lastMonthReviewedParams);
|
|
||||||
// console.log('上月已审核文件数量', lastMonthReviewed);
|
|
||||||
|
|
||||||
// 计算同比增长
|
|
||||||
let reviewGrowthValue = 0;
|
|
||||||
let reviewGrowthIsUp = true;
|
|
||||||
if (lastMonthReviewed > 0) {
|
|
||||||
const growthRate = ((monthlyReviewedFiles - lastMonthReviewed) / lastMonthReviewed) * 100;
|
|
||||||
reviewGrowthValue = Math.abs(parseFloat(growthRate.toFixed(1)));
|
|
||||||
reviewGrowthIsUp = growthRate >= 0;
|
|
||||||
} else if (lastMonthReviewed == 0 && monthlyReviewedFiles > 0) {
|
|
||||||
reviewGrowthValue = 100;
|
|
||||||
reviewGrowthIsUp = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 审核通过率 - 本月审核通过率
|
|
||||||
const thisMonthTotalParams: PostgrestParams = {
|
|
||||||
select: 'count',
|
|
||||||
filter: {
|
|
||||||
audit_status: `eq.1`,
|
|
||||||
created_at: `gte.${startOfThisMonth}`,
|
|
||||||
is_test_document: `eq.false`,
|
|
||||||
user_id: `eq.${userId}`
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 添加类型过滤条件
|
|
||||||
if (typeFilter) {
|
|
||||||
if (typeFilter.startsWith('(')) {
|
|
||||||
thisMonthTotalParams.or = typeFilter;
|
|
||||||
} else {
|
|
||||||
const [field, op, value] = typeFilter.split('.');
|
|
||||||
if (!thisMonthTotalParams.filter) {
|
|
||||||
thisMonthTotalParams.filter = {};
|
|
||||||
}
|
|
||||||
thisMonthTotalParams.filter[field] = `${op}.${value}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const thisMonthTotalCount = await handleApiResponse<{ count: number }[]>(
|
|
||||||
postgrestGet('documents', { ...thisMonthTotalParams, token }),
|
|
||||||
'获取本月审核通过数量失败',
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
// console.log('本月审核通过数量查询参数', thisMonthTotalParams);
|
|
||||||
// 本月审核通过数量
|
|
||||||
const thisMonthPassTotal = thisMonthTotalCount[0]?.count || 0;
|
|
||||||
// console.log('本月审核通过数量', thisMonthPassTotal);
|
|
||||||
// console.log('本月已审核文件数量', monthlyReviewedFiles);
|
|
||||||
|
|
||||||
// 本月审核通过率
|
|
||||||
const monthlyPassRate = (thisMonthPassTotal > 0 && monthlyReviewedFiles > 0)
|
|
||||||
? parseFloat(((thisMonthPassTotal / monthlyReviewedFiles) * 100).toFixed(1))
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
// 上月审核通过率
|
|
||||||
const lastMonthTotalParams: PostgrestParams = {
|
|
||||||
select: 'count',
|
|
||||||
filter: {
|
|
||||||
audit_status: `eq.1`,
|
|
||||||
and: `(upload_time.gte.${startOfLastMonth},upload_time.lte.${endOfLastMonth})`,
|
|
||||||
is_test_document: `eq.false`,
|
|
||||||
user_id: `eq.${userId}`
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 添加类型过滤条件
|
|
||||||
if (typeFilter) {
|
|
||||||
if (typeFilter.startsWith('(')) {
|
|
||||||
lastMonthTotalParams.or = typeFilter;
|
|
||||||
} else {
|
|
||||||
const [field, op, value] = typeFilter.split('.');
|
|
||||||
if (!lastMonthTotalParams.filter) {
|
|
||||||
lastMonthTotalParams.filter = {};
|
|
||||||
}
|
|
||||||
lastMonthTotalParams.filter[field] = `${op}.${value}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastMonthTotalCount = await handleApiResponse<{ count: number }[]>(
|
|
||||||
postgrestGet('documents', { ...lastMonthTotalParams, token }),
|
|
||||||
'获取上月审核通过数量失败',
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
// 上月审核通过数量
|
|
||||||
const lastMonthTotal = lastMonthTotalCount[0]?.count || 0;
|
|
||||||
|
|
||||||
// 上月审核通过率
|
|
||||||
const lastMonthPassRate = (lastMonthTotal > 0 && lastMonthReviewed > 0)
|
|
||||||
? parseFloat(((lastMonthTotal / lastMonthReviewed) * 100).toFixed(1))
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
// console.log('上个月-------', lastMonthPassRate);
|
|
||||||
|
|
||||||
// 计算通过率同比增长
|
|
||||||
let passRateGrowthValue = 0;
|
|
||||||
let passRateGrowthIsUp = true;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (lastMonthPassRate > 0) {
|
|
||||||
const passRateGrowth = ((monthlyPassRate - lastMonthPassRate) / lastMonthPassRate) * 100;
|
|
||||||
passRateGrowthValue = Math.abs(parseFloat(passRateGrowth.toFixed(1)));
|
|
||||||
passRateGrowthIsUp = passRateGrowth >= 0;
|
|
||||||
} else if (lastMonthPassRate == 0 && monthlyPassRate > 0) {
|
|
||||||
passRateGrowthValue = 100;
|
|
||||||
passRateGrowthIsUp = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// console.log('上月通过率-------', lastMonthPassRate);
|
|
||||||
// console.log('本月通过率-------', monthlyPassRate);
|
|
||||||
|
|
||||||
// 4. 检查出的问题总数(从评估结果表中统计)
|
|
||||||
// 使用新的数据库函数 count_evaluation_results_by_type 获取指定类型文档的问题数量
|
|
||||||
let thisMonthIssuesCount = 0;
|
|
||||||
let lastMonthIssuesCount = 0;
|
|
||||||
|
|
||||||
// 根据 reviewType 设置要查询的文档类型
|
|
||||||
if (reviewType === 'contract') {
|
|
||||||
// 合同类型 - 直接查询类型 1
|
|
||||||
const typeToQuery = [1];
|
|
||||||
|
|
||||||
// 调用数据库函数获取本月指定类型的问题数量
|
|
||||||
|
|
||||||
const thisMonthIssuesResponse = await handleApiResponse<{ count: number }[]>(
|
|
||||||
postgrestPost('rpc/count_evaluation_results_by_type', {
|
|
||||||
start_time: startOfThisMonth,
|
|
||||||
end_time: endOfThisMonth,
|
|
||||||
type_val: typeToQuery,
|
|
||||||
userid: parseInt(userId as string)
|
|
||||||
}, token),
|
|
||||||
'获取合同本月问题数据失败',
|
|
||||||
[]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 本月问题数量
|
if (response.error) {
|
||||||
thisMonthIssuesCount = thisMonthIssuesResponse[0]?.count || 0;
|
console.error('❌ [getHomeData] 获取统计数据失败:', response.error);
|
||||||
|
throw new Error(response.error);
|
||||||
// 调用数据库函数获取上月指定类型的问题数量
|
|
||||||
const lastMonthIssuesResponse = await handleApiResponse<{ count: number }[]>(
|
|
||||||
postgrestPost('rpc/count_evaluation_results_by_type', {
|
|
||||||
start_time: startOfLastMonth,
|
|
||||||
end_time: endOfLastMonth,
|
|
||||||
type_val: typeToQuery,
|
|
||||||
userid: parseInt(userId as string)
|
|
||||||
}, token),
|
|
||||||
'获取上月问题数据失败',
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 上月问题数量
|
|
||||||
lastMonthIssuesCount = lastMonthIssuesResponse[0]?.count || 0;
|
|
||||||
|
|
||||||
} else if (reviewType === 'record') {
|
|
||||||
// 记录类型 - 需要查询类型 2 和类型 3,并合并结果
|
|
||||||
const typeToQuery = [2,3];
|
|
||||||
|
|
||||||
const thisMonthType2Response = await handleApiResponse<{ count: number }[]>(
|
|
||||||
postgrestPost('rpc/count_evaluation_results_by_type', {
|
|
||||||
start_time: startOfThisMonth,
|
|
||||||
end_time: endOfThisMonth,
|
|
||||||
type_val: typeToQuery,
|
|
||||||
userid: parseInt(userId as string)
|
|
||||||
}, token),
|
|
||||||
'获取本月许可卷宗类型2问题数据失败',
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 本月两种类型的问题数量
|
|
||||||
const thisMonthType2Count = thisMonthType2Response[0]?.count || 0;
|
|
||||||
thisMonthIssuesCount = thisMonthType2Count
|
|
||||||
|
|
||||||
// 上月两种类型的问题数量
|
|
||||||
const lastMonthType2Response = await handleApiResponse<{ count: number }[]>(
|
|
||||||
postgrestPost('rpc/count_evaluation_results_by_type', {
|
|
||||||
start_time: startOfLastMonth,
|
|
||||||
end_time: endOfLastMonth,
|
|
||||||
type_val: typeToQuery,
|
|
||||||
userid: parseInt(userId as string)
|
|
||||||
}, token),
|
|
||||||
'获取上月许可卷宗类型2问题数据失败',
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 上月两种类型的问题数量
|
|
||||||
const lastMonthType2Count = lastMonthType2Response[0]?.count || 0;
|
|
||||||
lastMonthIssuesCount = lastMonthType2Count
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const backendData = response.data;
|
||||||
// 计算问题数量同比增长
|
if (!backendData) {
|
||||||
let issuesGrowthValue = 0;
|
console.error('❌ [getHomeData] 后端未返回数据');
|
||||||
let issuesGrowthIsUp = true;
|
throw new Error('后端未返回统计数据');
|
||||||
|
|
||||||
|
|
||||||
if (lastMonthIssuesCount > 0) {
|
|
||||||
const issuesGrowth = ((thisMonthIssuesCount - lastMonthIssuesCount) / lastMonthIssuesCount) * 100;
|
|
||||||
issuesGrowthValue = Math.abs(parseFloat(issuesGrowth.toFixed(1)));
|
|
||||||
issuesGrowthIsUp = issuesGrowth >= 0;
|
|
||||||
}else if(lastMonthIssuesCount == 0 && thisMonthIssuesCount > 0){
|
|
||||||
issuesGrowthValue = 100;
|
|
||||||
issuesGrowthIsUp = true;
|
|
||||||
}
|
}
|
||||||
// 返回统计结果
|
|
||||||
|
console.log('✅ [getHomeData] 获取统计数据成功:', backendData);
|
||||||
|
|
||||||
|
// 🔑 将后端响应(蛇形命名)转换为前端格式(驼峰命名)
|
||||||
return {
|
return {
|
||||||
todayPendingFiles,
|
todayPendingFiles: backendData.today_pending_files,
|
||||||
monthlyReviewedFiles,
|
monthlyReviewedFiles: backendData.monthly_reviewed_files,
|
||||||
monthlyReviewGrowth: {
|
monthlyReviewGrowth: {
|
||||||
value: reviewGrowthValue,
|
value: backendData.monthly_review_growth.value,
|
||||||
isUp: reviewGrowthIsUp
|
isUp: backendData.monthly_review_growth.is_up
|
||||||
},
|
},
|
||||||
monthlyPassRate,
|
monthlyPassRate: backendData.monthly_pass_rate,
|
||||||
passRateGrowth: {
|
passRateGrowth: {
|
||||||
value: passRateGrowthValue,
|
value: backendData.pass_rate_growth.value,
|
||||||
isUp: passRateGrowthIsUp
|
isUp: backendData.pass_rate_growth.is_up
|
||||||
},
|
},
|
||||||
issuesDetected: thisMonthIssuesCount,
|
issuesDetected: backendData.issues_detected,
|
||||||
issuesGrowth: {
|
issuesGrowth: {
|
||||||
value: issuesGrowthValue,
|
value: backendData.issues_growth.value,
|
||||||
isUp: issuesGrowthIsUp
|
isUp: backendData.issues_growth.is_up
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -486,6 +196,15 @@ export async function getHomeData(reviewType?: string | null,userId?: string | n
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 地区配置类型定义
|
||||||
|
*/
|
||||||
|
export interface AreaConfig {
|
||||||
|
area: string; // 地区名称
|
||||||
|
enabled: boolean; // 是否启用
|
||||||
|
sort_order: number; // 排序顺序
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 入口模块类型定义
|
* 入口模块类型定义
|
||||||
*/
|
*/
|
||||||
@@ -494,7 +213,7 @@ export interface EntryModule {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
path: string | null;
|
path: string | null;
|
||||||
areas: string[];
|
areas: AreaConfig[]; // 修改为对象数组
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
document_types?: Array<{
|
document_types?: Array<{
|
||||||
@@ -519,20 +238,11 @@ export async function getEntryModules(userRole: string | null | undefined, userA
|
|||||||
|
|
||||||
// console.log('🔍 [getEntryModules] 查询地区:', userArea);
|
// console.log('🔍 [getEntryModules] 查询地区:', userArea);
|
||||||
|
|
||||||
// 查询 entry_modules 表,筛选 areas 数组中包含用户地区的模块
|
// 查询 entry_modules 表,获取所有模块(在客户端进行过滤)
|
||||||
// 使用 PostgreSQL JSONB 操作符 @> 检查数组是否包含值
|
|
||||||
const params: PostgrestParams = {
|
const params: PostgrestParams = {
|
||||||
select: 'id,name,description,path,areas,created_at,updated_at',
|
select: 'id,name,description,path,areas,created_at,updated_at',
|
||||||
filter: {
|
filter: {}
|
||||||
// areas 数组中包含用户的 area
|
|
||||||
// areas: `cs.["${userArea}"]` // cs = contains (PostgreSQL @> 操作符)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
if (userRole != 'provincial_admin'){
|
|
||||||
params.filter = {
|
|
||||||
areas: `cs.["${userArea}"]`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const modulesResponse = await postgrestGet('entry_modules', { ...params, token });
|
const modulesResponse = await postgrestGet('entry_modules', { ...params, token });
|
||||||
|
|
||||||
@@ -541,13 +251,38 @@ export async function getEntryModules(userRole: string | null | undefined, userA
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const modules = extractApiData<EntryModule[]>(modulesResponse.data);
|
const allModules = extractApiData<EntryModule[]>(modulesResponse.data);
|
||||||
if (!modules || modules.length === 0) {
|
if (!allModules || allModules.length === 0) {
|
||||||
console.warn('⚠️ [getEntryModules] 未找到匹配的入口模块');
|
console.warn('⚠️ [getEntryModules] 未找到任何入口模块');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ [getEntryModules] 找到 ${modules.length} 个入口模块`);
|
// 🔑 在客户端过滤:只保留包含用户地区且已启用的模块
|
||||||
|
const modules = allModules.filter(module => {
|
||||||
|
// 省级管理员可以看到所有模块
|
||||||
|
if (userRole === 'provincial_admin') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 areas 数组中是否存在匹配的地区配置
|
||||||
|
if (!module.areas || !Array.isArray(module.areas)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找用户地区的配置
|
||||||
|
const areaConfig = module.areas.find(config =>
|
||||||
|
config.area === userArea && config.enabled === true
|
||||||
|
);
|
||||||
|
|
||||||
|
return !!areaConfig; // 找到且启用才返回 true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (modules.length === 0) {
|
||||||
|
console.warn('⚠️ [getEntryModules] 未找到已启用的入口模块');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ [getEntryModules] 找到 ${modules.length} 个已启用的入口模块`);
|
||||||
|
|
||||||
// 为每个模块查询关联的 document_types
|
// 为每个模块查询关联的 document_types
|
||||||
const modulesWithTypes = await Promise.all(
|
const modulesWithTypes = await Promise.all(
|
||||||
@@ -580,7 +315,7 @@ export async function getEntryModules(userRole: string | null | undefined, userA
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('✅ [getEntryModules] 入口模块数据(含文档类型):', JSON.stringify(modulesWithTypes));
|
// console.log('✅ [getEntryModules] 入口模块数据(含文档类型):', JSON.stringify(modulesWithTypes));
|
||||||
|
|
||||||
// 默认会多加一个 智慧法务大模型 入口 默认所有人都可以用,看到
|
// 默认会多加一个 智慧法务大模型 入口 默认所有人都可以用,看到
|
||||||
modulesWithTypes.push({
|
modulesWithTypes.push({
|
||||||
@@ -598,7 +333,8 @@ export async function getEntryModules(userRole: string | null | undefined, userA
|
|||||||
"code": "空"
|
"code": "空"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
return modulesWithTypes;
|
return modulesWithTypes;
|
||||||
|
|||||||
+29
-132
@@ -455,15 +455,27 @@ export async function logout(request: Request) {
|
|||||||
const accessToken = session.get("accessToken");
|
const accessToken = session.get("accessToken");
|
||||||
const appId = OAUTH_CONFIG.appId || 'idaasoauth2';
|
const appId = OAUTH_CONFIG.appId || 'idaasoauth2';
|
||||||
|
|
||||||
// 如果存在访问令牌,调用IDaaS单点登出
|
console.log("🚪 [Logout] 开始登出流程...");
|
||||||
|
console.log("🔑 [Logout] accessToken 存在:", !!accessToken);
|
||||||
|
console.log("📱 [Logout] appId:", appId);
|
||||||
|
|
||||||
|
// 如果存在访问令牌,调用IDaaS单点登出(仅 OAuth 登录用户)
|
||||||
if (accessToken && appId) {
|
if (accessToken && appId) {
|
||||||
|
console.log("🌐 [Logout] OAuth 用户,准备调用 IDaaS 单点登出...");
|
||||||
try {
|
try {
|
||||||
await callIDaaSLogout(accessToken, appId);
|
await callIDaaSLogout(accessToken, appId);
|
||||||
console.log("IDaaS单点登出成功");
|
console.log("✅ [Logout] IDaaS单点登出成功");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("IDaaS单点登出失败:", error);
|
console.error("❌ [Logout] IDaaS单点登出失败:");
|
||||||
|
console.error(" 错误详情:", error);
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.error(" 错误消息:", error.message);
|
||||||
|
console.error(" 错误堆栈:", error.stack);
|
||||||
|
}
|
||||||
// 即使IDaaS登出失败,也继续清除本地会话
|
// 即使IDaaS登出失败,也继续清除本地会话
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.log("ℹ️ [Logout] 管理员登录用户,无需调用 IDaaS 登出");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
@@ -487,6 +499,11 @@ async function callIDaaSLogout(accessToken: string, appId: string): Promise<void
|
|||||||
const redirectUri = OAUTH_CONFIG.redirectUri || 'http://10.79.97.17/';
|
const redirectUri = OAUTH_CONFIG.redirectUri || 'http://10.79.97.17/';
|
||||||
const logoutUrl = `${serverUrl}/public/sp/slo/${appId}`;
|
const logoutUrl = `${serverUrl}/public/sp/slo/${appId}`;
|
||||||
|
|
||||||
|
console.log("📡 [callIDaaSLogout] 准备发送登出请求:");
|
||||||
|
console.log(" 登出URL:", logoutUrl);
|
||||||
|
console.log(" 重定向URL:", redirectUri);
|
||||||
|
console.log(" accessToken:", accessToken ? `${accessToken.substring(0, 20)}...` : 'null');
|
||||||
|
|
||||||
const formData = new URLSearchParams();
|
const formData = new URLSearchParams();
|
||||||
formData.append('access_token', accessToken);
|
formData.append('access_token', accessToken);
|
||||||
formData.append('redirect_url', encodeURIComponent(redirectUri));
|
formData.append('redirect_url', encodeURIComponent(redirectUri));
|
||||||
@@ -498,13 +515,19 @@ async function callIDaaSLogout(accessToken: string, appId: string): Promise<void
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("IDaaS单点登出请求成功");
|
console.log("✅ [callIDaaSLogout] IDaaS单点登出请求成功");
|
||||||
|
console.log(" 响应状态:", response.status);
|
||||||
|
console.log(" 响应数据:", response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
console.error("调用IDaaS登出接口失败:", error.response?.status, error.response?.statusText);
|
console.error("❌ [callIDaaSLogout] 调用IDaaS登出接口失败:");
|
||||||
|
console.error(" HTTP状态:", error.response?.status);
|
||||||
|
console.error(" 状态文本:", error.response?.statusText);
|
||||||
|
console.error(" 响应数据:", error.response?.data);
|
||||||
|
console.error(" 请求配置:", error.config?.url, error.config?.method);
|
||||||
throw new Error(`IDaaS登出失败: ${error.response?.status} ${error.response?.statusText}`);
|
throw new Error(`IDaaS登出失败: ${error.response?.status} ${error.response?.statusText}`);
|
||||||
}
|
}
|
||||||
console.error("调用IDaaS登出接口失败:", error);
|
console.error("❌ [callIDaaSLogout] 调用IDaaS登出接口失败(非HTTP错误):", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -751,129 +774,3 @@ export async function getUserBySub(sub: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 账号密码登录接口
|
|
||||||
*
|
|
||||||
* @param username - 用户名
|
|
||||||
* @param password - 密码
|
|
||||||
* @param redirectTo - 登录成功后重定向的URL
|
|
||||||
* @returns HTTP重定向响应或错误响应
|
|
||||||
*/
|
|
||||||
export async function simpleRootLogin(
|
|
||||||
username: string,
|
|
||||||
password: string,
|
|
||||||
redirectTo: string
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
// 输入验证
|
|
||||||
if (!username?.trim() || !password?.trim()) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
success: false,
|
|
||||||
error: "用户名和密码不能为空"
|
|
||||||
}), {
|
|
||||||
status: 400,
|
|
||||||
headers: { "Content-Type": "application/json" }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用登录接口
|
|
||||||
const loginResponse = await axios.post(`${API_BASE_URL}/password_login`, {
|
|
||||||
sub: username.trim(),
|
|
||||||
password: password.trim()
|
|
||||||
}, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const loginResult = loginResponse.data;
|
|
||||||
console.log('登录接口返回', loginResult);
|
|
||||||
|
|
||||||
// 检查重试次数
|
|
||||||
const retryCount = loginResult.retryCount || loginResult.retry_count || 0;
|
|
||||||
console.log('登录重试次数:', retryCount);
|
|
||||||
|
|
||||||
if (loginResult.code === 0 && loginResult.data) {
|
|
||||||
// 登录成功,构建用户信息
|
|
||||||
const userData = loginResult.data;
|
|
||||||
// console.log('管理员登录userData', userData);
|
|
||||||
const userRole = userData.role; // 默认角色
|
|
||||||
|
|
||||||
// 生成模拟的OAuth token信息
|
|
||||||
const mockTokenExpiresIn = 7200; // 2小时
|
|
||||||
const mockAccessToken = `mock_access_token_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
const mockRefreshToken = `mock_refresh_token_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
|
|
||||||
// 生成前端JWT
|
|
||||||
const jwtUserInfo: UserInfoForJWT = {
|
|
||||||
sub: userData.sub,
|
|
||||||
user_id: userData.user_id,
|
|
||||||
username: userData.username,
|
|
||||||
nick_name: userData.nick_name,
|
|
||||||
email: userData.email,
|
|
||||||
phone_number: userData.phone_number,
|
|
||||||
ou_id: userData.ou_id,
|
|
||||||
ou_name: userData.ou_name,
|
|
||||||
is_leader: userData.is_leader,
|
|
||||||
user_role: userRole
|
|
||||||
};
|
|
||||||
|
|
||||||
const frontendJWT = JWTUtils.generateJWT(jwtUserInfo, mockTokenExpiresIn);
|
|
||||||
|
|
||||||
// 构建增强的用户信息对象
|
|
||||||
const enhancedUserInfo = {
|
|
||||||
...userData,
|
|
||||||
user_id: userData.user_id,
|
|
||||||
user_role: userRole,
|
|
||||||
frontend_jwt: frontendJWT
|
|
||||||
};
|
|
||||||
|
|
||||||
// 使用统一的session创建函数
|
|
||||||
return createUserSession({
|
|
||||||
isAuthenticated: true,
|
|
||||||
userRole: userRole,
|
|
||||||
redirectTo,
|
|
||||||
accessToken: mockAccessToken,
|
|
||||||
refreshToken: mockRefreshToken,
|
|
||||||
tokenExpiresIn: mockTokenExpiresIn,
|
|
||||||
userInfo: enhancedUserInfo,
|
|
||||||
frontendJWT
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 登录失败,检查账户是否被锁定
|
|
||||||
let errorMsg = loginResult.msg || "登录失败,请检查用户名和密码";
|
|
||||||
let isLocked = false;
|
|
||||||
|
|
||||||
// 检查是否因重试次数过多被锁定
|
|
||||||
if (retryCount >= 5) {
|
|
||||||
errorMsg = "账户已被锁定,密码错误次数过多,请联系管理员";
|
|
||||||
isLocked = true;
|
|
||||||
} else if (retryCount > 0) {
|
|
||||||
// 显示剩余尝试次数
|
|
||||||
const remainingAttempts = 5 - retryCount;
|
|
||||||
errorMsg = `${loginResult.msg || "用户名或密码错误"},还有 ${remainingAttempts} 次尝试机会`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
success: false,
|
|
||||||
error: errorMsg,
|
|
||||||
retryCount: retryCount,
|
|
||||||
isLocked: isLocked,
|
|
||||||
remainingAttempts: isLocked ? 0 : (5 - retryCount)
|
|
||||||
}), {
|
|
||||||
status: isLocked ? 403 : 401, // 403 表示禁止访问(账户被锁)
|
|
||||||
headers: { "Content-Type": "application/json" }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("登录请求失败:", error);
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
success: false,
|
|
||||||
error: "登录请求失败,请稍后重试"
|
|
||||||
}), {
|
|
||||||
status: 500,
|
|
||||||
headers: { "Content-Type": "application/json" }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -278,6 +278,22 @@ export async function postgrestGet<T>(endpoint: string, params?: PostgrestParams
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
|
// 🔑 检测令牌过期错误
|
||||||
|
const isTokenExpired = response.error.includes('令牌已过期') ||
|
||||||
|
response.error.includes('令牌') ||
|
||||||
|
response.error.includes('token') ||
|
||||||
|
response.error.includes('expired') ||
|
||||||
|
response.error.includes('认证') ||
|
||||||
|
response.error.includes('未授权');
|
||||||
|
|
||||||
|
if (isTokenExpired && typeof window !== 'undefined') {
|
||||||
|
console.error('🔑 [PostgREST Client - GET] 检测到令牌过期,清除会话并重定向到登录页');
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('user_info');
|
||||||
|
sessionStorage.clear();
|
||||||
|
window.location.href = '/login?expired=true';
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(response.error);
|
throw new Error(response.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,6 +437,23 @@ export async function postgrestPost<T, D = Record<string, unknown>>(endpoint: st
|
|||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
console.error(`POST请求失败: ${response.error}`);
|
console.error(`POST请求失败: ${response.error}`);
|
||||||
|
|
||||||
|
// 🔑 检测令牌过期错误
|
||||||
|
const isTokenExpired = response.error.includes('令牌已过期') ||
|
||||||
|
response.error.includes('令牌') ||
|
||||||
|
response.error.includes('token') ||
|
||||||
|
response.error.includes('expired') ||
|
||||||
|
response.error.includes('认证') ||
|
||||||
|
response.error.includes('未授权');
|
||||||
|
|
||||||
|
if (isTokenExpired && typeof window !== 'undefined') {
|
||||||
|
console.error('🔑 [PostgREST Client] 检测到令牌过期,清除会话并重定向到登录页');
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('user_info');
|
||||||
|
sessionStorage.clear();
|
||||||
|
window.location.href = '/login?expired=true';
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(response.error);
|
throw new Error(response.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,6 +583,22 @@ export async function postgrestPut<T, D extends object>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
|
// 🔑 检测令牌过期错误
|
||||||
|
const isTokenExpired = response.error.includes('令牌已过期') ||
|
||||||
|
response.error.includes('令牌') ||
|
||||||
|
response.error.includes('token') ||
|
||||||
|
response.error.includes('expired') ||
|
||||||
|
response.error.includes('认证') ||
|
||||||
|
response.error.includes('未授权');
|
||||||
|
|
||||||
|
if (isTokenExpired && typeof window !== 'undefined') {
|
||||||
|
console.error('🔑 [PostgREST Client - PATCH] 检测到令牌过期,清除会话并重定向到登录页');
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('user_info');
|
||||||
|
sessionStorage.clear();
|
||||||
|
window.location.href = '/login?expired=true';
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(response.error);
|
throw new Error(response.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,6 +646,22 @@ export async function postgrestDelete<T>(endpoint: string, params?: PostgrestPar
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
|
// 🔑 检测令牌过期错误
|
||||||
|
const isTokenExpired = response.error.includes('令牌已过期') ||
|
||||||
|
response.error.includes('令牌') ||
|
||||||
|
response.error.includes('token') ||
|
||||||
|
response.error.includes('expired') ||
|
||||||
|
response.error.includes('认证') ||
|
||||||
|
response.error.includes('未授权');
|
||||||
|
|
||||||
|
if (isTokenExpired && typeof window !== 'undefined') {
|
||||||
|
console.error('🔑 [PostgREST Client - DELETE] 检测到令牌过期,清除会话并重定向到登录页');
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('user_info');
|
||||||
|
sessionStorage.clear();
|
||||||
|
window.location.href = '/login?expired=true';
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(response.error);
|
throw new Error(response.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -90,12 +90,16 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid
|
|||||||
fetchUserRoutes();
|
fetchUserRoutes();
|
||||||
}, [userRole, frontendJWT, navigate]);
|
}, [userRole, frontendJWT, navigate]);
|
||||||
|
|
||||||
// 从 sessionStorage 读取当前选中的模块名称和图片路径
|
// 🔑 检查是否处于系统设置模式
|
||||||
|
const [isSettingsMode, setIsSettingsMode] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// 从 sessionStorage 读取当前选中的模块名称和图片路径,以及系统设置模式标志
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
try {
|
try {
|
||||||
const moduleName = sessionStorage.getItem('selectedModuleName');
|
const moduleName = sessionStorage.getItem('selectedModuleName');
|
||||||
const modulePicPath = sessionStorage.getItem('selectedModulePicPath');
|
const modulePicPath = sessionStorage.getItem('selectedModulePicPath');
|
||||||
|
const settingsMode = sessionStorage.getItem('settingsMode');
|
||||||
|
|
||||||
if (moduleName) {
|
if (moduleName) {
|
||||||
setSelectedModuleName(moduleName);
|
setSelectedModuleName(moduleName);
|
||||||
@@ -106,6 +110,14 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid
|
|||||||
setSelectedModulePicPath(modulePicPath);
|
setSelectedModulePicPath(modulePicPath);
|
||||||
console.log('🖼️ [Sidebar] 模块图片路径:', modulePicPath);
|
console.log('🖼️ [Sidebar] 模块图片路径:', modulePicPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔑 检查是否处于系统设置模式
|
||||||
|
if (settingsMode === 'true') {
|
||||||
|
setIsSettingsMode(true);
|
||||||
|
console.log('⚙️ [Sidebar] 进入系统设置模式');
|
||||||
|
} else {
|
||||||
|
setIsSettingsMode(false);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ [Sidebar] 读取 sessionStorage 失败:', error);
|
console.error('❌ [Sidebar] 读取 sessionStorage 失败:', error);
|
||||||
}
|
}
|
||||||
@@ -154,18 +166,29 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid
|
|||||||
// console.log('子菜单点击:', child.title, '路径:', child.path);
|
// 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 =>{
|
const processedMenuItems: MenuItem[] = menuItems.filter(item =>{
|
||||||
// console.log('菜单项:', item.title, 'Icon:', item.icon)
|
// console.log('菜单项:', item.title, 'Icon:', item.icon)
|
||||||
|
|
||||||
|
// 🔑 优先检查:如果处于系统设置模式,只显示 /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(isPort51707){
|
||||||
if (selectedModuleName === '智慧法务大模型'){
|
// if (selectedModuleName === '智慧法务大模型'){
|
||||||
return item.path && item.path.startsWith('/chat-with-llm')
|
// return item.path && item.path.startsWith('/chat-with-llm')
|
||||||
}
|
// }
|
||||||
return item.path && item.path.startsWith('/cross-checking')
|
// return item.path && item.path.startsWith('/cross-checking')
|
||||||
}
|
// }
|
||||||
|
|
||||||
// 🔑 如果选择了"智慧法务大模型",只显示 /chat-with-llm 相关菜单
|
// 🔑 如果选择了"智慧法务大模型",只显示 /chat-with-llm 相关菜单
|
||||||
if (selectedModuleName === '智慧法务大模型') {
|
if (selectedModuleName === '智慧法务大模型') {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { useState, useEffect, useRef, ChangeEvent } from 'react';
|
import { useState, useEffect, useRef, ChangeEvent } from 'react';
|
||||||
import { Document, Page, pdfjs } from 'react-pdf';
|
import { Document, Page, pdfjs } from 'react-pdf';
|
||||||
import { DOCUMENT_URL } from '~/api/axios-client';
|
import { DOCUMENT_URL } from '~/api/axios-client';
|
||||||
|
import { CollaboraViewer } from '~/components/collabora/CollaboraViewer';
|
||||||
|
|
||||||
// 设置worker路径为public目录下的worker文件
|
// 设置worker路径为public目录下的worker文件
|
||||||
// 使用已经下载的兼容版本 (pdfjs-dist v2.12.313)
|
// 使用已经下载的兼容版本 (pdfjs-dist v2.12.313)
|
||||||
@@ -73,10 +74,14 @@ interface FilePreviewProps {
|
|||||||
activeReviewPointResultId: string | null;
|
activeReviewPointResultId: string | null;
|
||||||
targetPage?: number; // 新增目标页码参数
|
targetPage?: number; // 新增目标页码参数
|
||||||
isStructuredView?: boolean; // 是否显示结构化视图
|
isStructuredView?: boolean; // 是否显示结构化视图
|
||||||
|
userInfo?: {
|
||||||
|
sub: string;
|
||||||
|
nick_name: string;
|
||||||
|
}; // 用户信息(用于 Collabora)
|
||||||
}
|
}
|
||||||
|
|
||||||
// export function FilePreview({ fileContent, reviewPoints, activeReviewPointResultId, targetPage }: FilePreviewProps) {
|
// 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 [zoomLevel, setZoomLevel] = useState(100);
|
||||||
// const [highlightsVisible, setHighlightsVisible] = useState(true);
|
// const [highlightsVisible, setHighlightsVisible] = useState(true);
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -461,8 +466,18 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage
|
|||||||
}
|
}
|
||||||
// 普通模式:仅显示PDF
|
// 普通模式:仅显示PDF
|
||||||
return renderPdfContent();
|
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 {
|
} else {
|
||||||
// 非PDF文件显示不支持消息
|
// 非PDF/DOCX文件显示不支持消息
|
||||||
return (
|
return (
|
||||||
<div className="text-gray-500 p-4">
|
<div className="text-gray-500 p-4">
|
||||||
<p>暂不支持预览此类型的文件:{fileExtension}</p>
|
<p>暂不支持预览此类型的文件:{fileExtension}</p>
|
||||||
|
|||||||
+33
-16
@@ -11,6 +11,10 @@ interface ApiConfig {
|
|||||||
documentUrl: string;
|
documentUrl: string;
|
||||||
// 文档上传API URL
|
// 文档上传API URL
|
||||||
uploadUrl: string;
|
uploadUrl: string;
|
||||||
|
// Collabora Online 服务器地址
|
||||||
|
collaboraUrl: string;
|
||||||
|
// 应用基础URL(用于 WOPI 回调)
|
||||||
|
appUrl: string;
|
||||||
// OAuth2.0配置
|
// OAuth2.0配置
|
||||||
oauth: {
|
oauth: {
|
||||||
// IDaaS服务器地址
|
// IDaaS服务器地址
|
||||||
@@ -33,15 +37,11 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
|
|||||||
// 主要
|
// 主要
|
||||||
// 梅州
|
// 梅州
|
||||||
'51703': {
|
'51703': {
|
||||||
// baseUrl: 'http://10.79.97.17:8000',
|
baseUrl: 'http://127.0.0.1:8073',
|
||||||
// documentUrl: 'http://10.79.97.17:8000/docauditai/',
|
documentUrl: 'http://127.0.0.1:8073/docauditai/',
|
||||||
// uploadUrl: 'http://10.79.97.17:8000/admin/documents',
|
uploadUrl: 'http://127.0.0.1:8073/admin/documents',
|
||||||
baseUrl: 'http://172.16.0.55:8073',
|
collaboraUrl: 'http://172.16.0.81:9980',
|
||||||
documentUrl: 'http://172.16.0.55:8073/docauditai/',
|
appUrl: 'http://10.79.97.17:51703',
|
||||||
uploadUrl: 'http://172.16.0.55:8073/admin/documents',
|
|
||||||
// baseUrl: 'http://nas.7bm.co:8073',
|
|
||||||
// documentUrl: 'http://nas.7bm.co:8073/docauditai/',
|
|
||||||
// uploadUrl: 'http://nas.7bm.co:8073/admin/documents',
|
|
||||||
oauth: {
|
oauth: {
|
||||||
redirectUri: 'http://10.79.97.17:51703/callback'
|
redirectUri: 'http://10.79.97.17:51703/callback'
|
||||||
}
|
}
|
||||||
@@ -53,6 +53,8 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
|
|||||||
baseUrl: 'http://10.79.97.17:8001',
|
baseUrl: 'http://10.79.97.17:8001',
|
||||||
documentUrl: 'http://10.79.97.17:8001/docauditai/',
|
documentUrl: 'http://10.79.97.17:8001/docauditai/',
|
||||||
uploadUrl: 'http://10.79.97.17:8001/admin/documents',
|
uploadUrl: 'http://10.79.97.17:8001/admin/documents',
|
||||||
|
collaboraUrl: 'http://10.79.97.17:9980',
|
||||||
|
appUrl: 'http://10.79.97.17:51704',
|
||||||
oauth: {
|
oauth: {
|
||||||
redirectUri: 'http://10.79.97.17:51704/callback'
|
redirectUri: 'http://10.79.97.17:51704/callback'
|
||||||
}
|
}
|
||||||
@@ -63,6 +65,8 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
|
|||||||
baseUrl: 'http://10.79.97.17:8002',
|
baseUrl: 'http://10.79.97.17:8002',
|
||||||
documentUrl: 'http://10.79.97.17:8002/docauditai/',
|
documentUrl: 'http://10.79.97.17:8002/docauditai/',
|
||||||
uploadUrl: 'http://10.79.97.17:8002/admin/documents',
|
uploadUrl: 'http://10.79.97.17:8002/admin/documents',
|
||||||
|
collaboraUrl: 'http://10.79.97.17:9980',
|
||||||
|
appUrl: 'http://10.79.97.17:51705',
|
||||||
oauth: {
|
oauth: {
|
||||||
redirectUri: 'http://10.79.97.17:51705/callback'
|
redirectUri: 'http://10.79.97.17:51705/callback'
|
||||||
}
|
}
|
||||||
@@ -73,6 +77,8 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
|
|||||||
baseUrl: 'http://10.79.97.17:8003',
|
baseUrl: 'http://10.79.97.17:8003',
|
||||||
documentUrl: 'http://10.79.97.17:8003/docauditai/',
|
documentUrl: 'http://10.79.97.17:8003/docauditai/',
|
||||||
uploadUrl: 'http://10.79.97.17:8003/admin/documents',
|
uploadUrl: 'http://10.79.97.17:8003/admin/documents',
|
||||||
|
collaboraUrl: 'http://10.79.97.17:9980',
|
||||||
|
appUrl: 'http://10.79.97.17:51706',
|
||||||
oauth: {
|
oauth: {
|
||||||
redirectUri: 'http://10.79.97.17:51706/callback'
|
redirectUri: 'http://10.79.97.17:51706/callback'
|
||||||
}
|
}
|
||||||
@@ -84,6 +90,8 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
|
|||||||
baseUrl: 'http://10.79.97.17:8004',
|
baseUrl: 'http://10.79.97.17:8004',
|
||||||
documentUrl: 'http://10.79.97.17:8004/docauditai/',
|
documentUrl: 'http://10.79.97.17:8004/docauditai/',
|
||||||
uploadUrl: 'http://10.79.97.17:8004/admin/documents',
|
uploadUrl: 'http://10.79.97.17:8004/admin/documents',
|
||||||
|
collaboraUrl: 'http://10.79.97.17:9980',
|
||||||
|
appUrl: 'http://10.79.97.17:51707',
|
||||||
oauth: {
|
oauth: {
|
||||||
redirectUri: 'http://10.79.97.17:51707/callback'
|
redirectUri: 'http://10.79.97.17:51707/callback'
|
||||||
}
|
}
|
||||||
@@ -93,7 +101,9 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
|
|||||||
'51708': {
|
'51708': {
|
||||||
baseUrl: 'http://10.79.97.17:8005',
|
baseUrl: 'http://10.79.97.17:8005',
|
||||||
documentUrl: 'http://10.79.97.17:8005/docauditai/',
|
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'
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,12 +112,11 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
|
|||||||
const configs: Record<string, ApiConfig> = {
|
const configs: Record<string, ApiConfig> = {
|
||||||
// 开发环境
|
// 开发环境
|
||||||
development: {
|
development: {
|
||||||
baseUrl: 'http://172.16.0.55:8073', // FastAPI后端(包含/dify代理)
|
baseUrl: 'http://172.16.0.78:8073', // FastAPI后端(包含/dify代理)
|
||||||
documentUrl: 'http://172.16.0.55:8073/docauditai/',
|
documentUrl: 'http://172.16.0.78:8073/docauditai/',
|
||||||
uploadUrl: 'http://172.16.0.55:8073/admin/documents',
|
uploadUrl: 'http://172.16.0.78:8073/admin/documents',
|
||||||
// baseUrl: 'http://nas.7bm.co:8073', // FastAPI后端(包含/dify代理)
|
collaboraUrl: 'http://172.16.0.81:9980',
|
||||||
// documentUrl: 'http://nas.7bm.co:8073/docauditai/',
|
appUrl: 'http://172.16.0.78:51703',
|
||||||
// uploadUrl: 'http://nas.7bm.co:8073/admin/documents',
|
|
||||||
oauth: {
|
oauth: {
|
||||||
serverUrl: 'http://10.79.112.85', // IDaaS服务器地址
|
serverUrl: 'http://10.79.112.85', // IDaaS服务器地址
|
||||||
clientId: 'none',
|
clientId: 'none',
|
||||||
@@ -122,6 +131,8 @@ const configs: Record<string, ApiConfig> = {
|
|||||||
baseUrl: 'http://nas.7bm.co:8873', // FastAPI后端(包含/dify代理)
|
baseUrl: 'http://nas.7bm.co:8873', // FastAPI后端(包含/dify代理)
|
||||||
documentUrl: 'http://nas.7bm.co:8873/docauditai/',
|
documentUrl: 'http://nas.7bm.co:8873/docauditai/',
|
||||||
uploadUrl: 'http://nas.7bm.co:8873/admin/documents',
|
uploadUrl: 'http://nas.7bm.co:8873/admin/documents',
|
||||||
|
collaboraUrl: 'http://10.79.97.17:9980',
|
||||||
|
appUrl: 'http://10.79.97.17:51703',
|
||||||
oauth: {
|
oauth: {
|
||||||
serverUrl: 'http://10.79.112.85', // IDaaS服务器地址
|
serverUrl: 'http://10.79.112.85', // IDaaS服务器地址
|
||||||
clientId: '54d2a619fe5c81ae1250434c441fccccqMtKwh7H4fO',
|
clientId: '54d2a619fe5c81ae1250434c441fccccqMtKwh7H4fO',
|
||||||
@@ -138,6 +149,8 @@ const configs: Record<string, ApiConfig> = {
|
|||||||
documentUrl: 'http://10.76.244.156:9000/docauditai/',
|
documentUrl: 'http://10.76.244.156:9000/docauditai/',
|
||||||
// 文件上传
|
// 文件上传
|
||||||
uploadUrl: 'http://10.79.97.17:8000/admin/documents',
|
uploadUrl: 'http://10.79.97.17:8000/admin/documents',
|
||||||
|
collaboraUrl: 'http://10.79.97.17:9980',
|
||||||
|
appUrl: 'http://10.79.97.17:51703',
|
||||||
oauth: {
|
oauth: {
|
||||||
clientId: '54d2a619fe5c81ae1250434c441fccccqMtKwh7H4fO',
|
clientId: '54d2a619fe5c81ae1250434c441fccccqMtKwh7H4fO',
|
||||||
serverUrl: 'http://10.79.112.85', // IDaaS服务器地址(测试)
|
serverUrl: 'http://10.79.112.85', // IDaaS服务器地址(测试)
|
||||||
@@ -155,6 +168,8 @@ const configs: Record<string, ApiConfig> = {
|
|||||||
baseUrl: 'http://172.16.0.119:9000/admin', // FastAPI后端(包含/dify代理)
|
baseUrl: 'http://172.16.0.119:9000/admin', // FastAPI后端(包含/dify代理)
|
||||||
documentUrl: 'http://nas.7bm.co:9000/docauditai/',
|
documentUrl: 'http://nas.7bm.co:9000/docauditai/',
|
||||||
uploadUrl: 'http://172.16.0.119:8000/admin/documents/upload',
|
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: {
|
oauth: {
|
||||||
serverUrl: 'http://10.79.112.85', // IDaaS服务器地址
|
serverUrl: 'http://10.79.112.85', // IDaaS服务器地址
|
||||||
clientId: 'none', // 需要替换为实际的Client ID
|
clientId: 'none', // 需要替换为实际的Client ID
|
||||||
@@ -332,6 +347,8 @@ export const {
|
|||||||
baseUrl: API_BASE_URL,
|
baseUrl: API_BASE_URL,
|
||||||
documentUrl: DOCUMENT_URL,
|
documentUrl: DOCUMENT_URL,
|
||||||
uploadUrl: UPLOAD_URL,
|
uploadUrl: UPLOAD_URL,
|
||||||
|
collaboraUrl: COLLABORA_URL,
|
||||||
|
appUrl: APP_URL,
|
||||||
oauth: OAUTH_CONFIG
|
oauth: OAUTH_CONFIG
|
||||||
} = apiConfig;
|
} = apiConfig;
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -149,7 +149,14 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
// 🔒 RBAC 路由权限检查
|
// 🔒 RBAC 路由权限检查
|
||||||
const { getUserRoutesByRole } = await import("~/api/auth/user-routes");
|
const { getUserRoutesByRole } = await import("~/api/auth/user-routes");
|
||||||
// 权限校验需要包含隐藏路由,确保用户可以访问隐藏的功能页面
|
// 权限校验需要包含隐藏路由,确保用户可以访问隐藏的功能页面
|
||||||
|
// console.log("🔒 [Root Loader] 开始调用 getUserRoutesByRole...");
|
||||||
const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true);
|
const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true);
|
||||||
|
// console.log("🔒 [Root Loader] getUserRoutesByRole 返回结果:", {
|
||||||
|
// success: routesResult.success,
|
||||||
|
// hasData: !!routesResult.data,
|
||||||
|
// error: routesResult.error,
|
||||||
|
// shouldRedirectToHome: routesResult.shouldRedirectToHome
|
||||||
|
// });
|
||||||
|
|
||||||
if (routesResult.success && routesResult.data) {
|
if (routesResult.success && routesResult.data) {
|
||||||
// 从菜单数据中提取所有允许的路径
|
// 从菜单数据中提取所有允许的路径
|
||||||
@@ -165,7 +172,24 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
throw new Response("无权访问此页面", { status: 403 });
|
throw new Response("无权访问此页面", { status: 403 });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 获取路由权限失败,只记录警告,不阻止访问(避免影响正常使用)
|
// 🔑 检查是否因为认证失败需要重定向到登录页
|
||||||
|
if (routesResult.shouldRedirectToHome) {
|
||||||
|
console.error("❌ [Root Loader] 获取用户路由权限失败,可能是令牌已过期,重定向到登录页");
|
||||||
|
console.error("❌ [Root Loader] 错误详情:", routesResult.error);
|
||||||
|
|
||||||
|
// 清除会话并重定向到登录页
|
||||||
|
const { sessionStorage } = await import("~/api/login/auth.server");
|
||||||
|
const session = await sessionStorage.getSession(request.headers.get("Cookie"));
|
||||||
|
const destroyedSession = await sessionStorage.destroySession(session);
|
||||||
|
|
||||||
|
return redirect("/login?expired=true", {
|
||||||
|
headers: {
|
||||||
|
"Set-Cookie": destroyedSession
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他错误,只记录警告,不阻止访问(避免影响正常使用)
|
||||||
console.warn("⚠️ [Root Loader] 获取用户路由权限失败,跳过权限检查");
|
console.warn("⚠️ [Root Loader] 获取用户路由权限失败,跳过权限检查");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+69
-9
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate, Form, useLoaderData } from '@remix-run/react';
|
import { useNavigate, Form, useLoaderData } from '@remix-run/react';
|
||||||
import { type MetaFunction, type ActionFunctionArgs, LoaderFunctionArgs, redirect } from "@remix-run/node";
|
import { type MetaFunction, type ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
|
||||||
import styles from "~/styles/pages/home.css?url";
|
import styles from "~/styles/pages/home.css?url";
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { getUserSession, logout } from "~/api/login/auth.server";
|
import { getUserSession, logout } from "~/api/login/auth.server";
|
||||||
@@ -49,8 +49,21 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
console.warn('⚠️ [Index Loader] 用户角色为空,返回空模块列表');
|
console.warn('⚠️ [Index Loader] 用户角色为空,返回空模块列表');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回用户信息和入口模块给客户端
|
// 🔑 检查用户是否有系统设置权限
|
||||||
return Response.json({ userRole, userInfo, entryModules });
|
let hasSettingsAccess = false;
|
||||||
|
if (userRole && frontendJWT) {
|
||||||
|
const { getUserRoutesByRole } = await import('~/api/auth/user-routes');
|
||||||
|
const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true); // includeHidden=true
|
||||||
|
|
||||||
|
if (routesResult.success && routesResult.data) {
|
||||||
|
// 检查是否存在顶级路由 '/settings'
|
||||||
|
hasSettingsAccess = routesResult.data.some(route => route.path === '/settings');
|
||||||
|
// console.log(`🔑 [Index Loader] 用户${hasSettingsAccess ? '有' : '没有'}系统设置权限`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回用户信息、入口模块和系统设置权限给客户端
|
||||||
|
return Response.json({ userRole, userInfo, entryModules, hasSettingsAccess });
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
@@ -62,7 +75,7 @@ export default function Index() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 检查是否通过51707端口访问
|
// 检查是否通过51707端口访问
|
||||||
const [isPort51707, setIsPort51707] = useState(false);
|
// const [isPort51707, setIsPort51707] = useState(false);
|
||||||
|
|
||||||
// 用户信息:优先使用服务端返回的,否则从 localStorage 读取
|
// 用户信息:优先使用服务端返回的,否则从 localStorage 读取
|
||||||
const [userInfo, setUserInfo] = useState(loaderData.userInfo);
|
const [userInfo, setUserInfo] = useState(loaderData.userInfo);
|
||||||
@@ -70,7 +83,7 @@ export default function Index() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
setIsPort51707(window.location.port === '51707');
|
// setIsPort51707(window.location.port === '51707');
|
||||||
|
|
||||||
// 如果服务端没有返回用户信息,从 localStorage 读取
|
// 如果服务端没有返回用户信息,从 localStorage 读取
|
||||||
if (!loaderData.userInfo || !loaderData.userRole) {
|
if (!loaderData.userInfo || !loaderData.userRole) {
|
||||||
@@ -91,10 +104,21 @@ export default function Index() {
|
|||||||
|
|
||||||
// 打印用户角色
|
// 打印用户角色
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('📋 [Index] 当前用户角色:', userRole);
|
// console.log('📋 [Index] 当前用户角色:', userRole);
|
||||||
console.log('👤 [Index] 当前用户信息:', userInfo);
|
// console.log('👤 [Index] 当前用户信息:', userInfo);
|
||||||
}, [userRole, userInfo]);
|
}, [userRole, userInfo]);
|
||||||
|
|
||||||
|
// 🔑 清除系统设置模式标志(当用户返回首页时)
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const settingsMode = sessionStorage.getItem('settingsMode');
|
||||||
|
if (settingsMode === 'true') {
|
||||||
|
sessionStorage.removeItem('settingsMode');
|
||||||
|
console.log('🔄 [Index] 清除系统设置模式标志');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []); // 只在组件挂载时执行一次
|
||||||
|
|
||||||
// 更新日期时间
|
// 更新日期时间
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateDateTime = () => {
|
const updateDateTime = () => {
|
||||||
@@ -200,6 +224,21 @@ export default function Index() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 处理进入系统设置
|
||||||
|
const handleEnterSettings = () => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// 🔑 设置标志:表示用户通过系统设置入口进入
|
||||||
|
sessionStorage.setItem('settingsMode', 'true');
|
||||||
|
// 清除模块相关的标志(因为不是从入口模块进入)
|
||||||
|
sessionStorage.removeItem('selectedModuleId');
|
||||||
|
sessionStorage.removeItem('selectedModuleName');
|
||||||
|
sessionStorage.removeItem('selectedModulePicPath');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳转到系统设置的默认页面
|
||||||
|
navigate('/rule-groups');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="home-page">
|
<div className="home-page">
|
||||||
{/* 登出表单 - 隐藏 */}
|
{/* 登出表单 - 隐藏 */}
|
||||||
@@ -250,7 +289,8 @@ export default function Index() {
|
|||||||
<div className="modules-container">
|
<div className="modules-container">
|
||||||
{/* 动态渲染入口模块 */}
|
{/* 动态渲染入口模块 */}
|
||||||
{loaderData.entryModules && loaderData.entryModules.length > 0 ? (
|
{loaderData.entryModules && loaderData.entryModules.length > 0 ? (
|
||||||
loaderData.entryModules.map((module) => (
|
<>
|
||||||
|
{loaderData.entryModules.map((module) => (
|
||||||
<div
|
<div
|
||||||
key={module.id}
|
key={module.id}
|
||||||
className="module-card"
|
className="module-card"
|
||||||
@@ -267,7 +307,27 @@ export default function Index() {
|
|||||||
/>
|
/>
|
||||||
<span className="module-name">{module.name}</span>
|
<span className="module-name">{module.name}</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))}
|
||||||
|
|
||||||
|
{/* 🔑 系统设置入口 - 只有有权限的用户才能看到 */}
|
||||||
|
{loaderData.hasSettingsAccess && (
|
||||||
|
<div
|
||||||
|
className="module-card"
|
||||||
|
onClick={handleEnterSettings}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
handleEnterSettings();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="系统设置"
|
||||||
|
>
|
||||||
|
<i className="ri-settings-4-line text-5xl text-primary"></i>
|
||||||
|
<span className="module-name">系统设置</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center text-gray-500 py-8">
|
<div className="text-center text-gray-500 py-8">
|
||||||
暂无可用模块
|
暂无可用模块
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* 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 '~/services/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 {
|
||||||
|
// 获取用户会话信息和 frontendJWT
|
||||||
|
const { userInfo, frontendJWT } = 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 frontendJWT
|
||||||
|
if (!frontendJWT) {
|
||||||
|
return json(
|
||||||
|
{ error: '用户未认证' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 Collabora 配置
|
||||||
|
const config = await generateCollaboraConfig({
|
||||||
|
fileId,
|
||||||
|
mode,
|
||||||
|
userId,
|
||||||
|
userName,
|
||||||
|
frontendJWT,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(config);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('生成 Collabora 配置失败:', error);
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : '生成配置失败',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* WOPI 协议 API 路由(Splat路由,支持多级路径)
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* - CheckFileInfo: GET /api/collabora/wopi/files/{...fileId}
|
||||||
|
* - GetFile: GET /api/collabora/wopi/files/{...fileId}/contents
|
||||||
|
* - PutFile: POST /api/collabora/wopi/files/{...fileId}/contents
|
||||||
|
*
|
||||||
|
* 注意:使用splat路由($)匹配多级文件路径,如 documents/mz/合同文档/2025/test.docx
|
||||||
|
*
|
||||||
|
* @encoding UTF-8
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type LoaderFunctionArgs, type ActionFunctionArgs } from '@remix-run/node';
|
||||||
|
import { WopiService } from '../services/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(使用 splat 参数 '*')
|
||||||
|
let fileId = params['*'] || '';
|
||||||
|
|
||||||
|
// 判断是否是 GetFile 请求(路径以 /contents 结尾)
|
||||||
|
const isContentsRequest = url.pathname.endsWith('/contents');
|
||||||
|
|
||||||
|
// 如果是 GetFile 请求,需要移除路径末尾的 /contents
|
||||||
|
if (isContentsRequest && fileId.endsWith('/contents')) {
|
||||||
|
fileId = fileId.slice(0, -9); // 移除 '/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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取文件 ID(使用 splat 参数 '*')
|
||||||
|
let fileId = params['*'] || '';
|
||||||
|
|
||||||
|
// 判断是否是 PutFile 请求(路径以 /contents 结尾)
|
||||||
|
const isContentsRequest = url.pathname.endsWith('/contents');
|
||||||
|
|
||||||
|
if (!isContentsRequest) {
|
||||||
|
return new Response('PutFile 必须使用 /contents 路径', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除路径末尾的 /contents
|
||||||
|
if (fileId.endsWith('/contents')) {
|
||||||
|
fileId = fileId.slice(0, -9);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 '../services/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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,8 +145,9 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
}
|
}
|
||||||
console.log("✅ [Callback] 用户信息获取成功");
|
console.log("✅ [Callback] 用户信息获取成功");
|
||||||
|
|
||||||
// 获取重定向URL
|
// 🔑 强制重定向到首页,确保用户选择入口模块并初始化 sessionStorage
|
||||||
const redirectTo = url.searchParams.get("redirect") || "/";
|
// 忽略 redirect 参数,总是跳转到首页让用户选择模块
|
||||||
|
const redirectTo = "/";
|
||||||
|
|
||||||
// 调用后端登录接口,传递 OAuth 用户信息,获取 JWT token
|
// 调用后端登录接口,传递 OAuth 用户信息,获取 JWT token
|
||||||
const loginRequest: LoginRequest = {
|
const loginRequest: LoginRequest = {
|
||||||
@@ -271,7 +272,8 @@ export default function Callback() {
|
|||||||
// 从 URL 参数中获取 token(如果有)
|
// 从 URL 参数中获取 token(如果有)
|
||||||
const token = searchParams.get("token");
|
const token = searchParams.get("token");
|
||||||
const userInfo = searchParams.get("userInfo");
|
const userInfo = searchParams.get("userInfo");
|
||||||
const redirectTo = searchParams.get("redirectTo") || "/";
|
// 🔑 强制重定向到首页,确保用户选择入口模块并初始化 sessionStorage
|
||||||
|
const redirectTo = "/";
|
||||||
|
|
||||||
if (token && typeof window !== 'undefined') {
|
if (token && typeof window !== 'undefined') {
|
||||||
console.log('🔑 [Callback] 开始保存 token 到 localStorage');
|
console.log('🔑 [Callback] 开始保存 token 到 localStorage');
|
||||||
|
|||||||
@@ -150,11 +150,13 @@ export default function ContractTemplateDetail() {
|
|||||||
}; */
|
}; */
|
||||||
|
|
||||||
// 创建文件内容对象用于FilePreview组件
|
// 创建文件内容对象用于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,
|
title: template.title,
|
||||||
contractNumber: template.template_code,
|
contractNumber: template.template_code,
|
||||||
// 使用pdf_file_path字段
|
// 使用file_path以支持多种格式(docx/pdf)
|
||||||
path: template.pdf_file_path,
|
path: previewPath,
|
||||||
parties: {
|
parties: {
|
||||||
partyA: {
|
partyA: {
|
||||||
name: '',
|
name: '',
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ export const meta: MetaFunction = () => {
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 面包屑配置
|
||||||
|
export const handle = {
|
||||||
|
breadcrumb: "入口模块管理"
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// 定义加载器返回的数据类型
|
// 定义加载器返回的数据类型
|
||||||
interface LoaderData {
|
interface LoaderData {
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ export const meta: MetaFunction = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const handle = {
|
export const handle = {
|
||||||
breadcrumb: "新建/编辑入口模块"
|
breadcrumb: "新建/编辑入口模块",
|
||||||
|
previousRoute: {
|
||||||
|
title: "入口模块管理",
|
||||||
|
to: "/entry-modules"
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 定义加载器返回的数据类型
|
// 定义加载器返回的数据类型
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const username = formData.get("username") as string;
|
const username = formData.get("username") as string;
|
||||||
const password = formData.get("password") as string;
|
const password = formData.get("password") as string;
|
||||||
const redirectTo = formData.get("redirectTo") as string || "/";
|
// 🔑 强制重定向到首页,确保用户选择入口模块并初始化 sessionStorage
|
||||||
|
const redirectTo = "/";
|
||||||
|
|
||||||
// 验证输入
|
// 验证输入
|
||||||
if (!username?.trim()) {
|
if (!username?.trim()) {
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Collabora Online 配置生成服务
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* - 生成 Collabora iframe URL
|
||||||
|
* - 生成 WOPI access token
|
||||||
|
* - 构建完整的 Collabora 配置
|
||||||
|
*
|
||||||
|
* @encoding UTF-8
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CollaboraConfig } from '~/components/collabora/types';
|
||||||
|
import { APP_URL, COLLABORA_URL } from '~/config/api-config';
|
||||||
|
import { WopiService } from './collabora.wopi.server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collabora 配置生成参数
|
||||||
|
*/
|
||||||
|
export interface GenerateConfigParams {
|
||||||
|
fileId: string;
|
||||||
|
mode: 'view' | 'edit';
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
frontendJWT: string; // 用户的前端JWT token(用于调用FastAPI)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 Collabora 配置
|
||||||
|
* @param params - 配置参数
|
||||||
|
* @returns Collabora 配置对象
|
||||||
|
*/
|
||||||
|
export async function generateCollaboraConfig(
|
||||||
|
params: GenerateConfigParams
|
||||||
|
): Promise<CollaboraConfig> {
|
||||||
|
const { fileId, mode, userId, userName, frontendJWT } = params;
|
||||||
|
|
||||||
|
// 创建 WOPI 服务实例
|
||||||
|
const wopiService = new WopiService();
|
||||||
|
|
||||||
|
// 生成 WOPI access token(2 小时有效期)
|
||||||
|
const accessToken = wopiService.generateAccessToken(
|
||||||
|
{
|
||||||
|
fileId,
|
||||||
|
mode,
|
||||||
|
userId,
|
||||||
|
userName,
|
||||||
|
frontendJWT,
|
||||||
|
},
|
||||||
|
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,269 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
frontendJWT: string; // 用户的前端JWT token(用于调用FastAPI)
|
||||||
|
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;
|
||||||
|
frontendJWT: string; // 用户的前端JWT token
|
||||||
|
}, 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,
|
||||||
|
frontendJWT: params.frontendJWT,
|
||||||
|
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',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${tokenData.frontendJWT}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
const tokenData = this.verifyAccessToken(accessToken, fileId);
|
||||||
|
|
||||||
|
// 清理文件路径
|
||||||
|
const sanitizedFileId = this.sanitizeFileId(fileId);
|
||||||
|
|
||||||
|
// 通过 FastAPI 代理获取文件内容
|
||||||
|
const fileUrl = `${DOCUMENT_URL}${sanitizedFileId}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(fileUrl, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${tokenData.frontendJWT}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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',
|
||||||
|
'Authorization': `Bearer ${tokenData.frontendJWT}`,
|
||||||
|
},
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
-16
@@ -80,7 +80,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: flex-start; /* 改为从顶部开始 */
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
background-color: #f0f7f4;
|
background-color: #f0f7f4;
|
||||||
@@ -91,25 +91,59 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.index-main-content-container {
|
.index-main-content-container {
|
||||||
padding: 2rem 0;
|
padding: 0;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
transform: translateY(-7rem);
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-text {
|
.welcome-text {
|
||||||
font-size: 1.95rem;
|
font-size: 1.95rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #333;
|
color: #333;
|
||||||
margin-bottom: 5rem;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
/* 标题固定在页面上方 1/4 处,水平垂直居中 */
|
||||||
|
height: 25vh; /* 占据上方 25% 的高度 */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0; /* 防止被压缩 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.modules-container {
|
.modules-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap; /* 自动换行 */
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
align-content: flex-start; /* 内容从顶部开始排列 */
|
||||||
gap: 2.5rem;
|
gap: 2.5rem;
|
||||||
margin-bottom: 3rem;
|
flex: 1; /* 占据剩余空间 */
|
||||||
|
overflow-y: auto; /* 超出高度时显示垂直滚动条 */
|
||||||
|
overflow-x: hidden; /* 隐藏水平滚动条 */
|
||||||
|
padding: 2rem 0 3rem 0; /* 上下留出一些空间 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条样式优化 */
|
||||||
|
.modules-container::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modules-container::-webkit-scrollbar-track {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modules-container::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 104, 74, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modules-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(0, 104, 74, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-card {
|
.module-card {
|
||||||
@@ -120,6 +154,7 @@
|
|||||||
padding: 0 2rem;
|
padding: 0 2rem;
|
||||||
height: 136px;
|
height: 136px;
|
||||||
width: 290px;
|
width: 290px;
|
||||||
|
flex-shrink: 0; /* 防止卡片被压缩 */
|
||||||
background: linear-gradient(180deg, #ebf1f7 0%, #ffffff 100%);
|
background: linear-gradient(180deg, #ebf1f7 0%, #ffffff 100%);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
@@ -234,22 +269,28 @@
|
|||||||
|
|
||||||
.index-main-content-container {
|
.index-main-content-container {
|
||||||
width: 95%;
|
width: 95%;
|
||||||
padding: 1rem 0;
|
padding: 0;
|
||||||
transform: translateY(-2rem);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-text {
|
.welcome-text {
|
||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
margin-bottom: 2.5rem;
|
height: 20vh; /* 移动端标题区域稍小一点 */
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 模块容器改为纵向排列 */
|
/* 模块容器改为纵向排列 */
|
||||||
.modules-container {
|
.modules-container {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex-wrap: nowrap; /* 移动端不需要换行 */
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
overflow-y: auto; /* 移动端超出长度滚动显示 */
|
||||||
|
padding: 1rem 0 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端滚动条样式 */
|
||||||
|
.modules-container::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 模块卡片调整 */
|
/* 模块卡片调整 */
|
||||||
@@ -259,6 +300,7 @@
|
|||||||
height: 100px;
|
height: 100px;
|
||||||
padding: 0 1.5rem;
|
padding: 0 1.5rem;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
|
flex-shrink: 0; /* 移动端也防止卡片被压缩 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-card img {
|
.module-card img {
|
||||||
@@ -299,7 +341,7 @@
|
|||||||
|
|
||||||
.welcome-text {
|
.welcome-text {
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
margin-bottom: 2rem;
|
height: 18vh; /* 超小屏幕标题区域更小 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-card {
|
.module-card {
|
||||||
@@ -327,20 +369,20 @@
|
|||||||
@media (min-width: 769px) and (max-width: 1024px) {
|
@media (min-width: 769px) and (max-width: 1024px) {
|
||||||
.index-main-content-container {
|
.index-main-content-container {
|
||||||
width: 85%;
|
width: 85%;
|
||||||
transform: translateY(-5rem);
|
}
|
||||||
|
|
||||||
|
.welcome-text {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
height: 22vh; /* 平板电脑标题区域高度 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.modules-container {
|
.modules-container {
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
|
padding: 1.5rem 0 2.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-card {
|
.module-card {
|
||||||
width: 260px;
|
width: 260px;
|
||||||
height: 120px;
|
height: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-text {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
margin-bottom: 4rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user