diff --git a/CLAUDE.md b/CLAUDE.md index f2e9eaf..19e3357 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,7 +146,93 @@ The system uses a **port-based multi-client architecture** where: - `MessageModal.tsx` - Confirmation/alert modal system - `Toast.tsx` - Toast notification provider - `LoadingBar.tsx` - Top loading bar for route transitions -- `FilePreview.tsx` - PDF/Word document preview +- `FilePreview.tsx` - PDF/Word document preview (react-pdf + Collabora integration) + +### Document Preview System + +**Current Implementation** (`app/components/reviews/FilePreview.tsx`): +- **PDF files**: Rendered using `react-pdf` library +- **DOCX files**: Needs Collabora Online integration (planned) + +**Integration Plan - Collabora Online for DOCX Preview**: + +The FilePreview component should support multiple file types: +1. `.pdf` → Use react-pdf (current implementation) +2. `.docx` → Use Collabora Online viewer (to be integrated from collabora-test project) + +**Key Pages Using FilePreview**: +- `app/routes/reviews.tsx` - Document review page (uses `document.path`) +- `app/routes/contract-template.detail.$id.tsx` - Contract template details (uses `template.file_path`) + +**Data Flow for Collabora Integration**: +``` +Page Component (reviews.tsx / contract-template.detail.$id.tsx) + ↓ passes fileContent.path +FilePreview Component (detects file extension) + ↓ if .docx +CollaboraViewer Component (app/components/collabora/) + ↓ calls API +app/routes/api.collabora.config.tsx (Remix loader) + ↓ generates JWT + WOPISrc URL + ↓ returns { iframeUrl, accessToken } +CollaboraViewer renders iframe + ↓ Collabora Online loads document + ↓ calls WOPI endpoints +app/routes/api.collabora.wopi.files.$fileId.tsx (Remix loader/action) + ↓ CheckFileInfo (GET) / GetFile (GET /contents) / PutFile (POST /contents) + ↓ interacts with MinIO storage +Returns document data to Collabora +``` + +**Files to Migrate from collabora-test**: +- `CollaboraViewer.tsx` - Main viewer component +- `hooks.ts` - useCollaboraConfig, useCollaboraUICustomization, useDocumentReady, useCollaboraUnoCommands +- `api.ts` - API client for Collabora config +- `Uno.ts` - LibreOffice UNO commands wrapper +- `CollaboraIframeUI.ts` - UI customization utilities +- `types.ts` - TypeScript type definitions + +**Backend API Routes to Create**: +1. `app/routes/api.collabora.config.tsx` - Generate Collabora iframe URL and JWT token +2. `app/routes/api.collabora.wopi.files.$fileId.tsx` - WOPI protocol implementation (CheckFileInfo, GetFile, PutFile) + +**Environment Variables Needed**: +```bash +# Collabora Online server URL +COLLABORA_URL=http://10.79.97.17:9980 + +# Application base URL (must be accessible from Collabora server) +APP_URL=http://10.79.97.17:51703 + +# JWT secret for WOPI token signing (reuse existing JWT_SECRET) +``` + +**Security Considerations**: +- JWT token must include `fileId` for WOPI endpoint validation +- File path sanitization to prevent directory traversal attacks +- CORS configuration for Collabora server to access WOPI endpoints +- WOPI CheckFileInfo should return pure JSON (not wrapped in API response format) + +**File Type Detection in FilePreview**: +```typescript +const fileExtension = fileContent.path.split('.').pop()?.toLowerCase(); + +if (fileExtension === 'pdf') { + // Use react-pdf + return ; +} else if (fileExtension === 'docx') { + // Use Collabora + return ; +} else { + // Unsupported format + return ; +} +``` + +**Reference Implementation**: +- See `collabora-test` workspace for complete working example +- Adapt Next.js API routes to Remix loader/action pattern +- Convert Next.js `route.ts` GET/POST to Remix `loader()` and `action()` functions **RemixIcon Usage**: - Icons are locally hosted in `public/fonts/` diff --git a/app/api/auth/user-routes.ts b/app/api/auth/user-routes.ts index b564fe8..f450bdc 100644 --- a/app/api/auth/user-routes.ts +++ b/app/api/auth/user-routes.ts @@ -493,11 +493,11 @@ const FALLBACK_MENU_DATA: Record = { */ export async function getUserRoutesByRole(roleKey: string, jwt?: string, includeHidden: boolean = false): Promise<{ success: boolean; data?: MenuItem[]; error?: string; shouldRedirectToHome?: boolean }> { try { - // console.log(`🔍 [User Routes] 获取用户路由,角色: ${roleKey}`); + // console.log(`🔍 [User Routes] 获取用户路由,角色: ${roleKey}, JWT前20字符: ${jwt?.substring(0, 20)}`); if (!jwt) { console.error('❌ [User Routes] JWT token 未提供'); - toastService.error("认证信息缺失,请重新登录"); + // 不显示 toast,让 root loader 处理重定向 return { success: false, error: "JWT token 未提供", shouldRedirectToHome: true }; } @@ -519,15 +519,34 @@ export async function getUserRoutesByRole(roleKey: string, jwt?: string, include // 检查响应是否成功 if (response.error) { console.error('❌ [User Routes] API 请求失败:', response.error); - toastService.error(response.error); - return { success: false, error: response.error, shouldRedirectToHome: true }; + // 🔑 如果是令牌过期错误,标记需要重定向到登录页 + 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); + } + return { success: false, error: response.error, shouldRedirectToHome: isTokenExpired }; } // 检查响应数据 if (!response.data) { console.error('❌ [User Routes] 后端未返回数据'); - toastService.error("获取路由数据失败"); - return { success: false, error: "后端未返回数据", shouldRedirectToHome: true }; + if (typeof window !== 'undefined') { + toastService.error("获取路由数据失败"); + } + return { success: false, error: "后端未返回数据", shouldRedirectToHome: false }; } const backendResponse = response.data; @@ -535,23 +554,45 @@ export async function getUserRoutesByRole(roleKey: string, jwt?: string, include // 检查业务状态码(后端使用 code: 0 表示成功) if (backendResponse.code !== 0 && backendResponse.code !== 200) { console.error(`❌ [User Routes] 后端返回错误: ${backendResponse.msg}`); - toastService.error(backendResponse.msg || "获取路由权限失败"); - return { success: false, error: backendResponse.msg || "获取路由权限失败", shouldRedirectToHome: true }; + // 🔑 如果是令牌过期错误,标记需要重定向到登录页 + 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 || "获取路由权限失败"); + } + return { success: false, error: backendResponse.msg || "获取路由权限失败", shouldRedirectToHome: isTokenExpired }; } // 检查数据完整性 if (!backendResponse.data || !Array.isArray(backendResponse.data.routes)) { console.error('❌ [User Routes] 后端未返回路由数据'); - toastService.error("未获取到路由权限,请联系管理员配置"); - return { success: false, error: "后端未返回路由数据", shouldRedirectToHome: true }; + if (typeof window !== 'undefined') { + toastService.error("未获取到路由权限,请联系管理员配置"); + } + return { success: false, error: "后端未返回路由数据", shouldRedirectToHome: false }; } const routes = backendResponse.data.routes; if (routes.length === 0) { console.log(`⚠️ [User Routes] 用户没有分配任何路由权限`); - toastService.error("您的角色没有分配任何路由权限,请联系管理员配置"); - return { success: false, error: "用户没有分配任何路由权限", shouldRedirectToHome: true }; + if (typeof window !== 'undefined') { + toastService.error("您的角色没有分配任何路由权限,请联系管理员配置"); + } + return { success: false, error: "用户没有分配任何路由权限", shouldRedirectToHome: false }; } // console.log('🔍 [User Routes] 后端返回的原始路由数据:', JSON.stringify(routes, null, 2)); @@ -568,11 +609,31 @@ export async function getUserRoutesByRole(roleKey: string, jwt?: string, include } catch (error) { console.error("❌ [User Routes] 获取用户路由时发生错误:", error); - toastService.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("获取用户路由时发生错误,请稍后再试"); + } + return { success: false, - error: `获取用户路由失败: ${error instanceof Error ? error.message : String(error)}`, - shouldRedirectToHome: true + error: `获取用户路由失败: ${errorMessage}`, + shouldRedirectToHome: isAuthError }; } } diff --git a/app/api/axios-client.ts b/app/api/axios-client.ts index 9c1c916..20bbd55 100644 --- a/app/api/axios-client.ts +++ b/app/api/axios-client.ts @@ -443,9 +443,35 @@ export async function apiRequest( // 检查API返回的状态码 const data = response.data; 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 { - error: data.message || data.msg || '请求失败', + error: errorMessage, status: response.status, headers: responseHeaders }; diff --git a/app/api/home/home.ts b/app/api/home/home.ts index aae9537..269bb67 100644 --- a/app/api/home/home.ts +++ b/app/api/home/home.ts @@ -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 dayjs from 'dayjs'; +// import dayjs from 'dayjs'; /** * 从不同格式的 API 响应中提取数据 @@ -78,397 +78,107 @@ interface HomeStatistics { } /** - * 通过传入的 reviewType 参数构建类型过滤条件 - * @param reviewType 文档类型 - * @returns 过滤条件字符串 + * 后端统计接口响应类型(蛇形命名) */ -function buildTypeFilter(reviewType: string | null): string { - let typeFilter = ''; - if (reviewType === 'contract') { - typeFilter = 'type_id.eq.1'; - } else if (reviewType === 'record') { - typeFilter = '(type_id.eq.2,type_id.eq.3)'; - } - return typeFilter; +interface BackendStatisticsResponse { + today_pending_files: number; + monthly_reviewed_files: number; + monthly_review_growth: { + value: number; + is_up: boolean; + }; + 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 userId 用户ID + * @param reviewType 从客户端传入的 reviewType 值(已废弃,现在从sessionStorage读取) + * @param userId 用户ID(已废弃,后端通过JWT自动识别) * @param token JWT token * @returns 主页数据 */ -export async function getHomeData(reviewType?: string | null,userId?: string | number, token?: string): Promise { +export async function getHomeData(reviewType?: string | null, userId?: string | number, token?: string): Promise { try { - // 获取当前日期和时间相关值 - const startOfToday = dayjs().startOf('day').format('YYYY-MM-DD HH:mm:ss'); - const startOfThisMonth = dayjs().startOf('month').format('YYYY-MM-DD HH:mm:ss'); - const endOfThisMonth = dayjs().endOf('month').format('YYYY-MM-DD HH:mm:ss'); - const startOfLastMonth = dayjs().subtract(1, 'month').startOf('month').format('YYYY-MM-DD HH:mm:ss'); - 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 ( - apiCall: Promise<{ - data?: unknown; - headers?: Record; - error?: string; - status?: number - }>, - errorMessage: string, - defaultValue: T - ): Promise => { - try { - const response = await apiCall; - if (response.error) { - console.error(`${errorMessage}: ${response.error}`); - return defaultValue; + // 🔑 从 sessionStorage 获取文档类型IDs + let typeIds: string | null = null; + if (typeof window !== 'undefined') { + const storedTypeIds = sessionStorage.getItem('documentTypeIds'); + if (storedTypeIds) { + try { + const typeIdsArray = JSON.parse(storedTypeIds) as number[]; + if (Array.isArray(typeIdsArray) && typeIdsArray.length > 0) { + typeIds = typeIdsArray.join(','); + console.log('📊 [getHomeData] 从 sessionStorage 获取文档类型:', typeIds); + } + } catch (error) { + console.error('❌ [getHomeData] 解析 documentTypeIds 失败:', error); } - const data = extractApiData(response.data); - if (!data) { - console.warn(`${errorMessage}: 无法提取有效数据`); - return defaultValue; - } - return data; - } catch (error) { - console.error(`${errorMessage}: ${error instanceof Error ? error.message : '未知错误'}`); - return defaultValue; - } - }; - - // 1. 今日待审核文件 - 获取今天的待审核文件数量 (audit_status = 0 或 2) - const todayPendingParams: PostgrestParams = { - select: 'count', - 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 }[]>( - postgrestGet('documents', { ...todayPendingParams, token }), - '获取今日待审核文件数量失败', - [] + // 🔑 构建请求参数 + const params: Record = { + time_range: '30days' // 默认近30天 + }; + + // 如果有文档类型,添加到参数 + if (typeIds) { + params.type_ids = typeIds; + } + + console.log('📊 [getHomeData] 请求参数:', params); + + // 🔑 调用后端统计接口 + const response = await apiRequest( + '/admin/statistics/home-data', + { + method: 'GET', + headers: token ? { + 'Authorization': `Bearer ${token}` + } : undefined + }, + params // 查询参数 ); - const todayPendingFiles = todayPendingCount[0]?.count || 0; - // 2. 本月已审核文件 - 获取本月已审核文件数量 (audit_status != 0 且 != 2) - const thisMonthReviewedParams: PostgrestParams = { - select: 'count', - filter: { - and: `(audit_status.neq.0,audit_status.neq.2)`, - upload_time: `gte.${startOfThisMonth}`, - is_test_document: `eq.false`, - user_id: `eq.${userId}` - } - }; - - // 添加类型过滤条件 - 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}`; - } + if (response.error) { + console.error('❌ [getHomeData] 获取统计数据失败:', response.error); + throw new Error(response.error); } - 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 backendData = response.data; + if (!backendData) { + console.error('❌ [getHomeData] 后端未返回数据'); + throw new Error('后端未返回统计数据'); } - 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; - } + console.log('✅ [getHomeData] 获取统计数据成功:', backendData); - // 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), - '获取合同本月问题数据失败', - [] - ); - - // 本月问题数量 - thisMonthIssuesCount = thisMonthIssuesResponse[0]?.count || 0; - - // 调用数据库函数获取上月指定类型的问题数量 - 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 - - } - - - // 计算问题数量同比增长 - let issuesGrowthValue = 0; - let issuesGrowthIsUp = true; - - - 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; - } - // 返回统计结果 + // 🔑 将后端响应(蛇形命名)转换为前端格式(驼峰命名) return { - todayPendingFiles, - monthlyReviewedFiles, + todayPendingFiles: backendData.today_pending_files, + monthlyReviewedFiles: backendData.monthly_reviewed_files, monthlyReviewGrowth: { - value: reviewGrowthValue, - isUp: reviewGrowthIsUp + value: backendData.monthly_review_growth.value, + isUp: backendData.monthly_review_growth.is_up }, - monthlyPassRate, + monthlyPassRate: backendData.monthly_pass_rate, passRateGrowth: { - value: passRateGrowthValue, - isUp: passRateGrowthIsUp + value: backendData.pass_rate_growth.value, + isUp: backendData.pass_rate_growth.is_up }, - issuesDetected: thisMonthIssuesCount, + issuesDetected: backendData.issues_detected, issuesGrowth: { - value: issuesGrowthValue, - isUp: issuesGrowthIsUp + value: backendData.issues_growth.value, + isUp: backendData.issues_growth.is_up } }; } 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; description: string | null; path: string | null; - areas: string[]; + areas: AreaConfig[]; // 修改为对象数组 created_at: string; updated_at: string; document_types?: Array<{ @@ -519,20 +238,11 @@ export async function getEntryModules(userRole: string | null | undefined, userA // console.log('🔍 [getEntryModules] 查询地区:', userArea); - // 查询 entry_modules 表,筛选 areas 数组中包含用户地区的模块 - // 使用 PostgreSQL JSONB 操作符 @> 检查数组是否包含值 + // 查询 entry_modules 表,获取所有模块(在客户端进行过滤) const params: PostgrestParams = { select: 'id,name,description,path,areas,created_at,updated_at', - filter: { - // areas 数组中包含用户的 area - // areas: `cs.["${userArea}"]` // cs = contains (PostgreSQL @> 操作符) - } + filter: {} }; - if (userRole != 'provincial_admin'){ - params.filter = { - areas: `cs.["${userArea}"]` - } - } const modulesResponse = await postgrestGet('entry_modules', { ...params, token }); @@ -541,13 +251,38 @@ export async function getEntryModules(userRole: string | null | undefined, userA return []; } - const modules = extractApiData(modulesResponse.data); - if (!modules || modules.length === 0) { - console.warn('⚠️ [getEntryModules] 未找到匹配的入口模块'); + const allModules = extractApiData(modulesResponse.data); + if (!allModules || allModules.length === 0) { + console.warn('⚠️ [getEntryModules] 未找到任何入口模块'); 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 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({ @@ -598,7 +333,8 @@ export async function getEntryModules(userRole: string | null | undefined, userA "code": "空" } ] - }) + } + ) return modulesWithTypes; diff --git a/app/api/login/auth.server.ts b/app/api/login/auth.server.ts index 3500b4c..c10563a 100644 --- a/app/api/login/auth.server.ts +++ b/app/api/login/auth.server.ts @@ -455,15 +455,27 @@ export async function logout(request: Request) { const accessToken = session.get("accessToken"); 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) { + console.log("🌐 [Logout] OAuth 用户,准备调用 IDaaS 单点登出..."); try { await callIDaaSLogout(accessToken, appId); - console.log("IDaaS单点登出成功"); + console.log("✅ [Logout] IDaaS单点登出成功"); } 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登出失败,也继续清除本地会话 } + } else { + console.log("ℹ️ [Logout] 管理员登录用户,无需调用 IDaaS 登出"); } return new Response(null, { @@ -487,6 +499,11 @@ async function callIDaaSLogout(accessToken: string, appId: string): Promise= 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" } - }); - } -} \ No newline at end of file diff --git a/app/api/postgrest-client.ts b/app/api/postgrest-client.ts index 92617bc..dcd9d95 100644 --- a/app/api/postgrest-client.ts +++ b/app/api/postgrest-client.ts @@ -276,13 +276,29 @@ export async function postgrestGet(endpoint: string, params?: PostgrestParams }, queryParams ); - + 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); } - + // 返回数据和响应头 - return { + return { data: response.data as T, headers: response.headers }; @@ -421,6 +437,23 @@ export async function postgrestPost>(endpoint: st if (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); } @@ -548,15 +581,31 @@ export async function postgrestPut( }, queryParams ); - + 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); } - + if (!response.data) { throw new Error('更新成功但未返回数据'); } - + return { data: response.data }; } catch (error) { const apiError = handleApiError(error); @@ -595,11 +644,27 @@ export async function postgrestDelete(endpoint: string, params?: PostgrestPar }, queryParams ); - + 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); } - + return { data: response.data as T }; } catch (error) { const apiError = handleApiError(error); diff --git a/app/components/collabora/CollaboraViewer.tsx b/app/components/collabora/CollaboraViewer.tsx new file mode 100644 index 0000000..a26e3e1 --- /dev/null +++ b/app/components/collabora/CollaboraViewer.tsx @@ -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(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 ( +
+
+
+

加载文档配置中...

+
+
+ ); + } + + // 错误状态 + if (error || !config) { + return ( +
+
+ +

{error || '加载配置失败'}

+

请刷新页面重试或联系管理员

+
+
+ ); + } + + return ( +
+ {/* 文档加载提示 */} + {!isDocumentLoaded && ( +
+
+
+

正在加载文档...

+

{config.fileName}

+
+
+ )} + + {/* Collabora iframe */} +