diff --git a/app/api/axios-client.ts b/app/api/axios-client.ts index 4022d6e..919f7b6 100644 --- a/app/api/axios-client.ts +++ b/app/api/axios-client.ts @@ -301,7 +301,7 @@ export async function apiRequest( // 检查API返回的状态码 const data = response.data; - if (data && '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}`); return { error: data.message || data.msg || '请求失败', diff --git a/app/api/evaluation_points/reviews.ts b/app/api/evaluation_points/reviews.ts index 37fb7e6..958bcc9 100644 --- a/app/api/evaluation_points/reviews.ts +++ b/app/api/evaluation_points/reviews.ts @@ -103,6 +103,12 @@ interface OcrData { [key: string]: unknown; } +interface ContractStructureComparison { + id: string | number; + document_id: string | number; + [key: string]: unknown; +} + /** * 获取当前评查文件的所有评查点结果 * @param fileId 评查文件ID @@ -116,6 +122,49 @@ export async function getReviewPoints(fileId: string) { return Response.json({ error: documentData.error }, { status: documentData.status || 500 }); } + // 其次需要查询这个文档关联的文档附件,查询contract_structure_comparison表 + const contractStructureComparisonParams: PostgrestParams = { + select: '*', + filter: { + 'document_id': `eq.${fileId}` + }, + order: 'id.desc', + limit: 1 + }; + const contractStructureComparisonResponse = await postgrestGet('contract_structure_comparison', contractStructureComparisonParams); + + if (contractStructureComparisonResponse.error) { + console.error("获取文档附件数据错误:", contractStructureComparisonResponse.error); + return Response.json({ error: contractStructureComparisonResponse.error }, { status: contractStructureComparisonResponse.status || 500 }); + } + + const contractStructureComparisonData = extractApiData(contractStructureComparisonResponse.data); + + // console.log('文档附件的数据', JSON.stringify(contractStructureComparisonData, null, 2)); + + // 解析比对结果 + let comparisonDocument = null; + if (contractStructureComparisonData && contractStructureComparisonData.length > 0) { + comparisonDocument = contractStructureComparisonData[0]; + // 测试:将合同封面中的status改为abnormal + // (comparisonDocument.comparison_results as Record>)['合同封面'][0]['status'] = 'abnormal'; + // 如果 comparison_results 是字符串,尝试解析为 JSON + if (comparisonDocument.comparison_results && typeof comparisonDocument.comparison_results === 'string') { + try { + comparisonDocument.comparison_results = JSON.parse(comparisonDocument.comparison_results); + } catch (e) { + console.error('解析比对结果失败:', e); + comparisonDocument.comparison_results = null; + } + } + }else{ + comparisonDocument = { + template_contract_path: '', + } + } + + + // console.log('documentData-------', documentData); // 步骤1:根据fileId查询evaluation_results表 const evaluationResultsParams: PostgrestParams = { select: '*', @@ -125,6 +174,7 @@ export async function getReviewPoints(fileId: string) { }; const evaluationResultsResponse = await postgrestGet('evaluation_results', evaluationResultsParams); + // console.log('evaluationResultsResponse-------', evaluationResultsResponse,); if (evaluationResultsResponse.error) { return { error: evaluationResultsResponse.error, status: evaluationResultsResponse.status }; } @@ -622,7 +672,7 @@ export async function getReviewPoints(fileId: string) { }; // console.log("reviewInfo-------",JSON.stringify(reviewInfo,null,2)); // data->reviewPoints stats->statistics reviewInfo->reviewInfo document->document - return { data: resultData, stats, reviewInfo, document: documentData.data }; + return { data: resultData, stats, reviewInfo, document: documentData.data, comparison_document: comparisonDocument }; } /** diff --git a/app/api/evaluation_points/rules-files.ts b/app/api/evaluation_points/rules-files.ts index 03b5b97..f6decdc 100644 --- a/app/api/evaluation_points/rules-files.ts +++ b/app/api/evaluation_points/rules-files.ts @@ -1,19 +1,11 @@ -import { postgrestGet, postgrestPut, type PostgrestParams } from '../postgrest-client'; +import { postgrestPut, postgrestPost } from '../postgrest-client'; // import dayjs from 'dayjs'; -import { getDocumentTypes } from '../document-types/document-types'; -import type { DocumentTypeUI } from '../document-types/document-types'; +// import { getDocumentTypes } from '../document-types/document-types'; +// import type { DocumentTypeUI } from '../document-types/document-types'; // import weekday from 'dayjs/plugin/weekday'; // import updateLocale from 'dayjs/plugin/updateLocale'; import { formatDate } from '../../utils'; -// // 配置 dayjs -// dayjs.extend(weekday); -// dayjs.extend(updateLocale); -// // 设置一周的第一天为周一 -// dayjs.updateLocale('en', { -// weekStart: 1 -// }); - // 文档数据库表接口 export interface Document { id: number; @@ -75,6 +67,33 @@ export interface ReviewFileUI { manualCount: number; } +// 数据库函数返回的评查文件结构 +interface ReviewFileFromSQL { + id: number; + status: string; + path: string; + file_name: string; + file_code: string; + file_type_name: string; + file_type_id: number; + file_size: number; + upload_time: string; + created_at: string; + evaluations_status: number; + audit_status: number | null; + created_by_user_id: number | null; + issue_count: number; + total_score: number; + pass_count: number; + warning_count: number; + fail_count: number; + manual_count: number; + issues: Array<{ + severity: 'info' | 'warning' | 'error' | 'critical'; + message: string; + }> | null; +} + // 文件列表搜索参数 export interface DocumentSearchParams { fileType?: string; // 文件类型ID @@ -90,56 +109,56 @@ export interface DocumentSearchParams { // 添加评查结果和评查点类型定义 // 评查结果类型 -interface EvaluationResult { - id: string | number; - document_id: string | number; - evaluation_point_id: string | number; - evaluated_results?: { - result?: boolean; - message?: string; - data?: string; - [key: string]: unknown; - }; - [key: string]: unknown; -} +// interface EvaluationResult { +// id: string | number; +// document_id: string | number; +// evaluation_point_id: string | number; +// evaluated_results?: { +// result?: boolean; +// message?: string; +// data?: string; +// [key: string]: unknown; +// }; +// [key: string]: unknown; +// } // 评查点类型 -interface EvaluationPoint { - id: string | number; - post_action?: string; - score?: number; - [key: string]: unknown; -} +// interface EvaluationPoint { +// id: string | number; +// post_action?: string; +// score?: number; +// [key: string]: unknown; +// } // 文档评查状态结果 -interface DocumentReviewResult { - status: number; - issueCount: number; - passCount: number; - warningCount: number; - failCount: number; - manualCount: number; -} +// interface DocumentReviewResult { +// status: number; +// issueCount: number; +// passCount: number; +// warningCount: number; +// failCount: number; +// manualCount: number; +// } -/** - * 从不同格式的 API 响应中提取数据 - * @param responseData API 响应数据 - * @returns 提取后的数据或 null - */ -function extractApiData(responseData: unknown): T | null { - if (!responseData) return null; +// /** +// * 从不同格式的 API 响应中提取数据 +// * @param responseData API 响应数据 +// * @returns 提取后的数据或 null +// */ +// function extractApiData(responseData: unknown): T | null { +// if (!responseData) return null; - // 格式1: { code: number, msg: string, data: T } - if (typeof responseData === 'object' && responseData !== null && - 'code' in responseData && - 'data' in responseData && - (responseData as { data: unknown }).data) { - return (responseData as { data: T }).data; - } +// // 格式1: { code: number, msg: string, data: T } +// if (typeof responseData === 'object' && responseData !== null && +// 'code' in responseData && +// 'data' in responseData && +// (responseData as { data: unknown }).data) { +// return (responseData as { data: T }).data; +// } - // 格式2: 直接是数据对象 - return responseData as T; -} +// // 格式2: 直接是数据对象 +// return responseData as T; +// } /** * 将评查状态代码映射到UI状态 @@ -180,44 +199,6 @@ export function getFileExtension(fileName: string): string { return fileName.split('.').pop()?.toLowerCase() || ''; } -/** - * 将数据库文档转换为UI文件对象 - * @param document 数据库文档 - * @param documentTypeName 文档类型名称 - * @returns UI文件对象 - */ -export function convertToReviewFileUI(document: Document, documentTypeName: string): ReviewFileUI { - // 将评查状态转换为UI状态(这个评查状态后续可能不需要,这里先预留) - const reviewStatus = mapReviewStatusToUI(document.evaluations_status); - - const reviewFileUI: ReviewFileUI = { - id: document.id.toString(), - status: document.status, - path: document.path, - fileName: document.name, - fileCode: document.document_number, - fileType: documentTypeName, - fileTypeId: document.type_id, - fileSize: document.file_size, - uploadTime: formatDate(document.created_at), - reviewStatus: reviewStatus, - reviewStatusCode: document.evaluations_status || 0, - issueCount: 0, - score: 0, - auditStatus: document.audit_status, - issues: [], - createdBy: document.user_id?.toString() || '系统', - passCount: 0, - warningCount: 0, - failCount: 0, - manualCount: 0 - }; - -// console.log('reviewFileUI-----',reviewFileUI); - - return reviewFileUI; -} - /** * 获取评查文件列表 * @param searchParams 搜索参数 @@ -229,334 +210,88 @@ export async function getReviewFiles(searchParams: DocumentSearchParams = {}): P status?: number; }> { try { - const page = searchParams.page || 1; - const pageSize = searchParams.pageSize || 10; - - // 构建查询参数 - const params: PostgrestParams = { - select: '*', - order: 'created_at.desc', - headers: { - 'Prefer': 'count=exact' - }, - limit: pageSize, - offset: (page - 1) * pageSize, - filter: {} as Record - }; - - // 根据排序方式设置排序 - if (searchParams.sortOrder) { - switch (searchParams.sortOrder) { - case 'upload_time_desc': - params.order = 'created_at.desc'; - break; - case 'upload_time_asc': - params.order = 'created_at.asc'; - break; - // case 'issue_count_desc': - // params.order = 'issue_count.desc'; - // break; - // case 'issue_count_asc': - // params.order = 'issue_count.asc'; - // break; - } - } - - // 添加筛选条件 - const filter: Record = {}; - - // 处理文件类型筛选 - if (searchParams.fileType) { - // console.log('API处理文件类型筛选:', searchParams.fileType); - // 特殊处理 'record' 类型,表示 type_id 为 2 或 3 - if (searchParams.fileType === 'record') { - filter['type_id'] = 'in.(2,3)'; + const { + page = 1, + pageSize = 10, + keyword, + fileType, // sessionStorage.getItem('reviewType') + reviewStatus, + dateFrom, + dateTo, + sortOrder = 'upload_time_desc' + } = searchParams; + + let p_typeid: number[] | null = null; + if (fileType) { + if (fileType === 'record') { + p_typeid = [2, 3]; + } else if (fileType === 'contract') { + p_typeid = [1]; } else { - filter['type_id'] = `eq.${Number(searchParams.fileType)}`; - } - } - - if (searchParams.reviewStatus) { - const statusValue = mapUIToReviewStatus(searchParams.reviewStatus); - filter['evaluations_status'] = `eq.${statusValue}`; - } - - if (searchParams.keyword) { - filter['or'] = `(name.ilike.%${searchParams.keyword}%,document_number.ilike.%${searchParams.keyword}%)`; - } - - // 处理日期范围筛选 - if(searchParams.dateFrom){ - filter['created_at'] = `gte.${searchParams.dateFrom+ ' 00:00:00'}`; - } - - if(searchParams.dateTo){ - const dateToKey = searchParams.dateFrom ? 'and' : 'created_at'; - if(dateToKey === 'and'){ - delete filter['created_at']; - filter[dateToKey] = `(created_at.gte.${searchParams.dateFrom+' 00:00:00'},created_at.lte.${searchParams.dateTo+' 23:59:59'})`; - }else{ - filter['created_at'] = `lte.${searchParams.dateTo+' 23:59:59'}`; - } - } - // if (searchParams.dateRange) { - // const now = dayjs(); - // const today = now.startOf('day').format('YYYY-MM-DD HH:mm:ss'); - // switch (searchParams.dateRange) { - // case 'today': - // filter['created_at'] = `gte.${today}`; - // break; - // case 'week': { - // const weekStart = now.startOf('week').format('YYYY-MM-DD HH:mm:ss'); - // filter['created_at'] = `gte.${weekStart}`; - // break; - // } - // case 'month': { - // const monthStart = now.startOf('month').format('YYYY-MM-DD HH:mm:ss'); - // filter['created_at'] = `gte.${monthStart}`; - // break; - // } - // } - // } - - params.filter = filter; - // console.log('API请求参数:', params); - - // 发送API请求获取文档列表 - const response = await postgrestGet('documents', params); - - if (response.error) { - return { error: response.error, status: response.status }; - } - - // 提取API返回的数据 - const extractedDocuments = extractApiData(response.data); - - if (!extractedDocuments) { - return { error: '获取评查文件数据失败', status: 500 }; - } - - // 从响应头中获取总数 - let totalCount = 0; - const responseWithHeaders = response as { data: Document[]; headers: Record }; - if(responseWithHeaders.headers){ - const rangeHeader = responseWithHeaders.headers['content-range']; - if(rangeHeader){ - const total = rangeHeader.split('/')[1]; - if(total !== '*'){ - totalCount = parseInt(total, 10); + const typeId = parseInt(fileType, 10); + if (!isNaN(typeId)) { + p_typeid = [typeId]; } } } - - // 获取文档类型数据,用于查找文档类型名称 - const documentTypesResponse = await getDocumentTypes({pageSize: 500}); - const documentTypes = documentTypesResponse.data?.types || []; - - // 创建文档类型ID到名称的映射 - const typeNameMap: Record = {}; - documentTypes.forEach((type: DocumentTypeUI) => { - typeNameMap[type.id] = type.name; - }); - // 获取评查文件的评查结果 - // 第一步:收集所有文档ID - const documentIds = extractedDocuments.map(doc => doc.id); - - // 第二步:查询所有文档的评查结果数据 - const evaluationResultParams: PostgrestParams = { - select: '*', - filter: { - 'document_id': `in.(${documentIds.join(',')})` - } + const rpcParams = { + p_keyword: keyword || null, + p_typeid: p_typeid, + p_evaluations_status: reviewStatus ? mapUIToReviewStatus(reviewStatus) : null, + p_date_from: dateFrom || null, + p_date_to: dateTo || null, }; - const evaluationResultsResponse = await postgrestGet('evaluation_results', evaluationResultParams); - let evaluationResults: EvaluationResult[] = []; + const listParams = { + ...rpcParams, + p_page: page, + p_page_size: pageSize, + p_sort_order: sortOrder + }; + + // 并行执行获取数据和获取总数的请求 + const [filesResponse, countResponse] = await Promise.all([ + postgrestPost('rpc/get_review_files_with_details', listParams), + postgrestPost('rpc/count_review_files', rpcParams) + ]); - if (!evaluationResultsResponse.error) { - evaluationResults = extractApiData(evaluationResultsResponse.data) || []; + // 处理获取文档列表的错误 + if (filesResponse.error || !filesResponse.data) { + return { error: filesResponse.error || '获取文档数据失败', status: filesResponse.status || 500 }; } - // 第三步:收集所有评查点ID - const evaluationPointIds = evaluationResults - .map(result => result.evaluation_point_id) - .filter(Boolean); - - // 第四步:获取评查点数据 - let evaluationPoints: EvaluationPoint[] = []; - if (evaluationPointIds.length > 0) { - const evaluationPointsParams: PostgrestParams = { - select: '*', - filter: { - 'id': `in.(${evaluationPointIds.join(',')})` - } - }; - - const evaluationPointsResponse = await postgrestGet('evaluation_points', evaluationPointsParams); - if (!evaluationPointsResponse.error) { - evaluationPoints = extractApiData(evaluationPointsResponse.data) || []; - } + // 处理获取总数的错误 + if (countResponse.error || typeof countResponse.data !== 'number') { + console.error('获取文档总数失败:', countResponse.error); } - // 创建评查点ID到评查点数据的映射 - const pointsMap = new Map(); - evaluationPoints.forEach(point => { - pointsMap.set(point.id, point); - }); + const totalCount = typeof countResponse.data === 'number' ? countResponse.data : 0; - // 创建文档ID到评查结果列表的映射 - const documentResultsMap = new Map(); - evaluationResults.forEach(result => { - const docId = result.document_id; - if (!documentResultsMap.has(docId)) { - documentResultsMap.set(docId, []); - } - documentResultsMap.get(docId)!.push(result); - }); + // 将SQL返回的数据转换为UI格式 + const reviewFiles: ReviewFileUI[] = filesResponse.data.map((file: ReviewFileFromSQL) => ({ + id: file.id.toString(), + status: file.status, + path: file.path, + fileName: file.file_name, + fileCode: file.file_code, + fileType: file.file_type_name, + fileTypeId: file.file_type_id, + fileSize: file.file_size, + uploadTime: formatDate(file.created_at), + reviewStatus: mapReviewStatusToUI(file.evaluations_status), + reviewStatusCode: file.evaluations_status, + issueCount: file.issue_count, + score: file.total_score, + auditStatus: file.audit_status, + issues: file.issues || [], + createdBy: file.created_by_user_id?.toString() || '系统', + passCount: file.pass_count, + warningCount: file.warning_count, + failCount: file.fail_count, + manualCount: file.manual_count, + })); - // 计算每个文档的评查状态和问题列表 - const documentStatusMap = new Map(); - // 存储每个文档的问题消息 - const documentIssuesMap = new Map>(); - // 存储每个文档的分数 - const documentScoreMap = new Map(); - - documentIds.forEach(docId => { - const results = documentResultsMap.get(docId) || []; - - // 1. 首先检查是否有需要人工审核的评查点 - let hasManualReviewPoint = false; - let hasFailResult = false; - let totalScore = 0; - let totalPoints = 0; - let totalPassPoints = 0; - let totalWarningPoints = 0; - let totalFailPoints = 0; - let totalManualPoints = 0; - - // 存储该文档的问题消息 - const issuesList: Array<{severity: 'info' | 'warning' | 'error' | 'critical', message: string}> = []; - - for (const result of results) { - const evaluatedResults = result.evaluated_results || {}; - const resultValue = evaluatedResults.result; - const pointId = result.evaluation_point_id; - const point = pointsMap.get(pointId); - - // 统计需要人工审核的评查点 - if (point && point.post_action === 'manual') { - hasManualReviewPoint = true; - totalManualPoints++; - } - - // 检查是否有不通过的结果 - if (!resultValue) { - hasFailResult = true; - - // 收集问题消息 - if (evaluatedResults.message) { - issuesList.push({ - severity: 'error', - message: evaluatedResults.message as string - }); - } - - // 统计不通过而且评查点是警告的评查点 - if (point && (point.suggestion_message_type === 'warning' || point.suggestion_message_type === 'info')) { - totalWarningPoints++; - }else if (point && point.suggestion_message_type === 'error') { - totalFailPoints++; - } - - }else{ - totalPassPoints++; - } - - // 计算总分 - if (point) { - totalScore += point.score || 0; - totalPoints++; - } - } - - // 保存文档的问题列表 - documentIssuesMap.set(docId, issuesList); - - // 计算并保存文档的分数 - const calculatedScore = totalScore || 100; - documentScoreMap.set(docId, calculatedScore); - - // 根据优先级确定评查状态 - let status = 1; // 默认为通过 - - // 待人工确认优先级最高 - if (hasManualReviewPoint) { - status = 0; // 待人工确认 - } - // 警告次之 - else if (hasFailResult) { - status = -2; // 警告 - } - // 最后判断分数 - else { - // 如果没有评查点,默认为通过 - if (totalPoints > 0) { - // 通过分数线为80分 - // status = totalScore >= 80 ? 1 : -1; // 通过或不通过 - // 通过率为80% - status = parseFloat((totalPassPoints/totalPoints).toFixed(1)) >= 0.8 ? 1 : -1; // 通过或不通过 - } - } - - documentStatusMap.set(docId, { - status, - passCount: totalPassPoints, - warningCount: totalWarningPoints, - failCount: totalFailPoints, - manualCount: totalManualPoints, - issueCount: results.filter(r => r.evaluated_results?.result === false).length - }); - }); - - // console.log("documentStatusMap-----",documentStatusMap); - - // 将文档数据转换为UI文件对象,同时应用评查状态 - const reviewFiles = extractedDocuments.map(doc => { - const typeName = typeNameMap[doc.type_id] || '未知类型'; - const reviewResult = documentStatusMap.get(doc.id) || { status: doc.evaluations_status || 0, issueCount: 0, passCount: 0, warningCount: 0, failCount: 0, manualCount: 0 }; - const issues = documentIssuesMap.get(doc.id) || []; - const score = documentScoreMap.get(doc.id) || 100; // 获取计算后的分数,默认为100 - - // 如果文档的评查状态与计算结果不同,更新文档的评查状态 - if (doc.evaluations_status !== reviewResult.status) { - // 异步更新文档评查状态 - postgrestPut('documents', - { evaluations_status: reviewResult.status }, - { id: doc.id } - ).catch(err => console.error(`更新文档${doc.id}评查状态失败:`, err)); - } - - const reviewFile = convertToReviewFileUI(doc, typeName); - - // 覆盖文档的评查状态和问题计数 - reviewFile.reviewStatusCode = reviewResult.status; - reviewFile.reviewStatus = mapReviewStatusToUI(reviewResult.status); - reviewFile.issueCount = reviewResult.issueCount; - - reviewFile.passCount = reviewResult.passCount; - reviewFile.warningCount = reviewResult.warningCount; - reviewFile.failCount = reviewResult.failCount; - reviewFile.manualCount = reviewResult.manualCount; - - reviewFile.score = score; // 添加分数 - // 添加问题列表 - reviewFile.issues = issues; - - return reviewFile; - }); - - // console.log('reviewFiles-----',reviewFiles); return { data: { files: reviewFiles, @@ -620,7 +355,7 @@ export async function getReviewFiles(searchParams: DocumentSearchParams = {}): P // return { // error: error instanceof Error ? error.message : '更新评查状态失败', // status: 500 -// }; +// } // } // } diff --git a/app/api/files/documents.ts b/app/api/files/documents.ts index 7a7d74c..213f9a4 100644 --- a/app/api/files/documents.ts +++ b/app/api/files/documents.ts @@ -1,4 +1,4 @@ -import { postgrestGet, postgrestDelete, postgrestPut, type PostgrestParams } from '../postgrest-client'; +import { postgrestGet, postgrestDelete, postgrestPut, postgrestPost } from '../postgrest-client'; import { getDocumentTypes } from '../document-types/document-types'; import { formatDate } from '../../utils'; @@ -169,6 +169,29 @@ async function convertToUIDocument(doc: Document): Promise { }; } +/** + * 后端SQL函数返回的文档结构 + */ +interface DocumentFromSQL { + id: number; + name: string; + document_number: string; + type_id: number; + type_name: string; + file_size: number; + audit_status: number; + status: string; + false_count: number; + updated_at: string; + path: string; + is_test_document: boolean; + ocr_result: { + __meta?: { + page_count?: number; + } + }; +} + /** * 获取文档列表 * @param searchParams 搜索参数 @@ -180,124 +203,85 @@ export async function getDocuments(searchParams: DocumentSearchParams = {}): Pro status?: number; }> { try { - const page = searchParams.page || 1; - const pageSize = searchParams.pageSize || 10; - - // 构建查询参数 - const params: PostgrestParams = { - select: '*', - order: 'updated_at.desc', - headers: { - 'Prefer': 'count=exact' - }, - limit: pageSize, - offset: (page - 1) * pageSize, - filter: {} as Record - }; - - // 添加筛选条件 - const filter: Record = {}; - - if (searchParams.name) { - filter['name'] = `ilike.%${searchParams.name}%`; - } - - if (searchParams.documentNumber) { - filter['document_number'] = `ilike.%${searchParams.documentNumber}%`; - } - - if (searchParams.documentType) { - filter['type_id'] = `eq.${searchParams.documentType}`; - } - - if (searchParams.auditStatus) { - // 处理"待审核"状态 - 特殊处理 audit_status = 0 的情况,同时包含 null 值 - if (searchParams.auditStatus === '0') { - filter['or'] = `(audit_status.eq.0,audit_status.is.null)`; - } else { - filter['audit_status'] = `eq.${searchParams.auditStatus}`; - } - } - - if (searchParams.fileStatus) { - filter['status'] = `eq.${searchParams.fileStatus}`; - } - - // 处理日期范围 - if (searchParams.dateFrom) { - // 添加当天开始时间 00:00:00 - filter['updated_at'] = `gte.${searchParams.dateFrom + ' 00:00:00'}`; - } - - if (searchParams.dateTo) { - // 如果有开始日期,使用and条件;否则直接设置结束日期 - const dateToKey = searchParams.dateFrom ? 'and' : 'updated_at'; - // 添加当天结束时间 23:59:59 - if (dateToKey === 'and') { - delete filter['updated_at']; - // 使用OR操作符连接两个条件 - filter[dateToKey] = `(updated_at.gte.${searchParams.dateFrom+' 00:00:00'},updated_at.lte.${searchParams.dateTo+' 23:59:59'})`; - } else { - filter['updated_at'] = `lte.${searchParams.dateTo+' 23:59:59'}`; - } - } - - // 根据 reviewType 添加过滤条件 - if (searchParams.reviewType) { - // 如果已经有文档类型过滤,则不再添加 reviewType 的过滤 - if (!searchParams.documentType) { - if (searchParams.reviewType === 'contract') { - // 如果是合同类型,只显示 type_id=1 的文档 - filter['type_id'] = 'eq.1'; - } else if (searchParams.reviewType === 'record') { - // 如果是卷宗类型,只显示 type_id=2 或 type_id=3 的文档 - filter['type_id'] = 'in.(2,3)'; - } - } - } - - // console.log('filter-----', filter); - params.filter = filter; - - // 发送请求 - const response = await postgrestGet('documents', params); - - if (response.error) { - return { error: response.error, status: response.status }; - } - - // 提取数据 - const extractedData = extractApiData(response.data); - if (!extractedData) { - return { error: '获取文档数据失败', status: 500 }; - } - - // console.log('extractedData---1--',extractedData[0]); + // 准备RPC调用参数 + const { + page = 1, + pageSize = 10, + name, + documentNumber, + documentType, + auditStatus, + fileStatus, + dateFrom, + dateTo, + reviewType + } = searchParams; - // 转换为UI格式 - const documents = await Promise.all(extractedData.map(convertToUIDocument)); - // console.log('documentsItem',documents) - // 获取总数 - let totalCount = 0; - const responseWithHeaders = response as { - data: unknown; - headers?: Record - }; - - if (responseWithHeaders.headers) { - const rangeHeader = responseWithHeaders.headers['content-range']; - if (rangeHeader) { - const total = rangeHeader.split('/')[1]; - if (total !== '*') { - totalCount = parseInt(total, 10); - } + let documentTypes: number[] | undefined; + if (documentType) { + documentTypes = [parseInt(documentType, 10)]; + } else if (reviewType) { + if (reviewType === 'contract') { + documentTypes = [1]; + } else if (reviewType === 'record') { + documentTypes = [2, 3]; } } + const rpcParams = { + search_name: name, + search_document_number: documentNumber, + search_document_types: documentTypes, + search_audit_status: auditStatus !== undefined ? parseInt(auditStatus, 10) : undefined, + search_file_status: fileStatus, + search_date_from: dateFrom, + search_date_to: dateTo, + }; + + // 并行执行获取数据和获取总数的请求 + const [documentsResponse, countResponse] = await Promise.all([ + postgrestPost('rpc/get_documents_with_filters', { ...rpcParams, page, page_size: pageSize }), + postgrestPost('rpc/count_documents_with_filters', rpcParams) + ]); + + // 处理获取文档列表的错误 + if (documentsResponse.error || !documentsResponse.data) { + return { error: documentsResponse.error || '获取文档数据失败', status: documentsResponse.status || 500 }; + } + + // 处理获取总数的错误 + if (countResponse.error || typeof countResponse.data !== 'number') { + // 如果计数失败,可以继续返回数据,但总数可能不准 + console.error('获取文档总数失败:', countResponse.error); + } + // console.log('countResponse.data', countResponse.data); + + const totalCount = typeof countResponse.data === 'number' ? countResponse.data : 0; + + // 将SQL返回的数据转换为UI格式 + const documents: DocumentUI[] = documentsResponse.data.map((doc: DocumentFromSQL) => ({ + id: doc.id, + name: doc.name, + documentNumber: doc.document_number, + type: doc.type_id.toString(), + typeName: doc.type_name || '未知类型', + size: doc.file_size, + auditStatus: doc.audit_status ?? 0, + fileStatus: doc.status || '', + issues: doc.false_count ?? null, + uploadTime: formatDate(doc.updated_at), + fileType: getFileExtension(doc.name), + path: doc.path, + isTest: doc.is_test_document, + updatedAt: formatDate(doc.updated_at), + pageCount: doc.ocr_result?.__meta?.page_count || 0, + ocrResult: doc.ocr_result + })); + return { data: { documents, - total: totalCount || documents.length + total: totalCount } }; } catch (error) { diff --git a/app/api/files/files-upload.ts b/app/api/files/files-upload.ts index 3fb2b6c..fea784a 100644 --- a/app/api/files/files-upload.ts +++ b/app/api/files/files-upload.ts @@ -70,6 +70,19 @@ export interface Document { audit_status?: number; } +// 合同结构比较表接口 +export interface ContractStructureComparison { + id: number; + template_contract_name: string; + file_size: number; + status: DocumentStatus; + created_at: string; + document_id?: number; + template_contract_path?: string; + ocr_results?: Record; + comparison_results?: Record; +} + // 文件上传响应接口 export interface FileUploadResponse { success: boolean; @@ -118,6 +131,7 @@ export async function uploadFileToBinary(file: File): Promise { * @param documentNumber 文档编号(可选) * @param remark 备注信息(可选) * @param isTestDocument 是否为测试文档 + * @param documentId 关联的文档ID(用于合同附件上传) * @returns 上传结果 */ export async function uploadDocumentToServer( @@ -128,7 +142,9 @@ export async function uploadDocumentToServer( priority: string, documentNumber?: string | null, remark?: string | null, - isTestDocument: boolean = false + isTestDocument: boolean = false, + documentId?: number | null, + isReupload: boolean = false ): Promise<{data: FileUploadResponse; error?: never} | {data?: never; error: string; status?: number}> { try { // console.log('【调试】开始上传文档:', { fileName, fileSize: binaryData.byteLength }); @@ -147,22 +163,25 @@ export async function uploadDocumentToServer( evaluation_level: priority, document_number: documentNumber || null, remark: remark || null, - is_test_document: isTestDocument + is_test_document: isTestDocument, + document_id: documentId || null, + is_reupload: isReupload }; // 添加JSON字符串到FormData formData.append('upload_info', JSON.stringify(uploadInfo)); // console.log('【调试】FormData准备完成:', JSON.stringify(uploadInfo)); - // console.log('【调试】准备发送请求到服务器:', UPLOAD_URL); + // 根据是否有documentId决定使用哪个接口 + const uploadEndpoint = documentId ? '/upload_contract_template' : '/upload'; + const uploadUrl = UPLOAD_URL + uploadEndpoint; + // console.log('【调试】准备发送请求到服务器:', uploadUrl); // 发送请求 // const response = await fetch(`${API_BASE_URL}/admin/documents/upload`, { try { // console.log('【调试】开始fetch请求...'); - const response = await fetch(UPLOAD_URL, { - // const response = await fetch('http://172.16.0.55:8000/admin/documents/upload', { - // const response = await fetch('http://172.16.0.119:8000/admin/documents/upload', { + const response = await fetch(uploadUrl, { method: 'POST', headers: { 'X-File-Name': encodeURIComponent(fileName) @@ -230,6 +249,126 @@ export async function getTodayDocuments(reviewType?: string): Promise<{data: Doc const today = dayjs().startOf('day').format('YYYY-MM-DD'); // console.log('查询当天文档,日期范围:', today); + // 如果是合同类型,需要合并查询documents表和contract_structure_comparison表 + if (reviewType === 'contract') { + try { + // 查询documents表中的合同数据 + const documentsParams: PostgrestParams = { + select: ` + id, + name, + type_id, + file_size, + status, + created_at, + document_number, + path, + storage_type, + is_test_document, + evaluation_level, + ocr_result, + extracted_results, + sumary, + remark, + audit_status + `, + order: 'created_at.desc', + filter: { + 'created_at': `gte.${today}`, + 'type_id': 'eq.1' + } + }; + + // 查询contract_structure_comparison表中的数据 + // const comparisonParams: PostgrestParams = { + // select: ` + // id, + // template_contract_name, + // file_size, + // status, + // created_at, + // document_id, + // template_contract_path, + // ocr_results, + // comparison_results + // `, + // order: 'created_at.desc', + // filter: { + // 'created_at': `gte.${today}` + // } + // }; + + // 并行查询两个表 + // const [documentsResponse, comparisonResponse] = await Promise.all([ + // postgrestGet('documents', documentsParams), + // postgrestGet('contract_structure_comparison', comparisonParams) + // ]); + + const documentsResponse = await postgrestGet('documents', documentsParams); + + // console.log('documents表响应:', documentsResponse); + // console.log('contract_structure_comparison表响应:', comparisonResponse); + + // if (documentsResponse.error && comparisonResponse.error) { + // console.error('两个表查询都失败:', documentsResponse.error, comparisonResponse.error); + // return { error: documentsResponse.error || comparisonResponse.error, status: documentsResponse.status || comparisonResponse.status }; + // } + if (documentsResponse.error) { + console.error('documents表查询失败:', documentsResponse.error); + return { error: documentsResponse.error, status: documentsResponse.status }; + } + + // 提取documents表数据 + let documentsData: Document[] = []; + if (!documentsResponse.error && documentsResponse.data) { + const extractedDocuments = extractApiData(documentsResponse.data); + if (extractedDocuments) { + documentsData = extractedDocuments; + } + } + + // 提取contract_structure_comparison表数据并转换为Document格式 + // let comparisonData: Document[] = []; + // if (!comparisonResponse.error && comparisonResponse.data) { + // const extractedComparison = extractApiData(comparisonResponse.data); + // if (extractedComparison) { + // // 将ContractStructureComparison转换为Document格式 + // console.log('extractedComparison:', extractedComparison); + // comparisonData = extractedComparison.map(item => ({ + // id: item.id, + // name: item.template_contract_name || `合同结构比较记录_${item.id}`, + // type_id: 1, // 合同结构比较默认为合同类型 + // file_size: item.file_size || 0, + // status: item.status, + // created_at: item.created_at, + // document_id: item.document_id, + // template_contract_path: item.template_contract_path, + // ocr_results: item.ocr_results, + // comparison_results: item.comparison_results + // })); + // } + // } + + // 合并两个数据源 + // const allData = [...documentsData, ...comparisonData]; + const allData = [...documentsData]; + + // 按created_at降序排序 + allData.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + + // console.log('合并后的数据:', allData); + return { data: allData }; + + } catch (contractError) { + console.error('合同类型查询失败:', contractError); + return { + error: contractError instanceof Error ? contractError.message : '合同类型查询失败', + status: 500 + }; + } + } + + // 非合同类型的原有逻辑 const params: PostgrestParams = { select: ` id, @@ -256,14 +395,7 @@ export async function getTodayDocuments(reviewType?: string): Promise<{data: Doc }; // 根据reviewType添加过滤条件 - if (reviewType === 'contract') { - // 如果是合同类型,只显示type_id=1的文档 - if (params.filter) { - params.filter['type_id'] = 'eq.1'; - } else { - params.filter = { 'type_id': 'eq.1' }; - } - } else if (reviewType === 'record') { + if (reviewType === 'record') { // 如果是卷宗类型,只显示type_id=2或type_id=3的文档 if (params.filter) { params.filter['type_id'] = 'in.(2,3)'; @@ -352,33 +484,76 @@ export async function getDocumentTypes(reviewType?: string): Promise<{data: Docu /** * 获取指定文档的状态 * @param documentIds 文档ID列表 + * @param attachmentIds 合同附件ID列表(可选) * @returns 文档状态列表 */ -export async function getDocumentsStatus(documentIds: number[]): Promise<{data: Document[]; error?: never} | {data?: never; error: string; status?: number}> { +export async function getDocumentsStatus( + documentIds: number[], + attachmentIds?: number[] +): Promise<{data: Document[]; error?: never} | {data?: never; error: string; status?: number}> { try { - if (!documentIds || documentIds.length === 0) { + if ((!documentIds || documentIds.length === 0) && (!attachmentIds || attachmentIds.length === 0)) { return { data: [] }; } - const params: PostgrestParams = { - select: 'id, status', - filter: { - 'id': `in.(${documentIds.join(',')})` + // 查询主文档状态 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let documentsResponse: any = { data: [], error: undefined, status: undefined }; + if (documentIds && documentIds.length > 0) { + const documentsParams: PostgrestParams = { + select: 'id, status', + filter: { + 'id': `in.(${documentIds.join(',')})` + } + }; + documentsResponse = await postgrestGet('documents', documentsParams); + } + + // 查询合同附件状态 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let attachmentResponse: any = { data: [], error: undefined, status: undefined }; + if (attachmentIds && attachmentIds.length > 0) { + const attachmentParams: PostgrestParams = { + select: 'id, status', + filter: { + 'id': `in.(${attachmentIds.join(',')})` + } + }; + attachmentResponse = await postgrestGet('contract_structure_comparison', attachmentParams); + } + + if (documentsResponse.error && attachmentResponse.error) { + return { error: documentsResponse.error || attachmentResponse.error, status: documentsResponse.status || attachmentResponse.status }; + } + + let allData: Document[] = []; + + // 处理主文档数据 + if (!documentsResponse.error && documentsResponse.data) { + const extractedDocuments = extractApiData(documentsResponse.data); + if (extractedDocuments) { + allData = [...allData, ...extractedDocuments]; } - }; - - const response = await postgrestGet('documents', params); - - if (response.error) { - return { error: response.error, status: response.status }; } - const extractedData = extractApiData(response.data); - if (!extractedData) { - return { error: '获取数据失败', status: 500 }; + // 处理合同附件数据 + if (!attachmentResponse.error && attachmentResponse.data) { + const extractedAttachments = extractApiData(attachmentResponse.data); + if (extractedAttachments) { + // 将ContractStructureComparison转换为Document格式 + const convertedAttachments: Document[] = extractedAttachments.map(item => ({ + id: item.id, + name: item.template_contract_name || `合同结构比较记录_${item.id}`, + type_id: 1, + file_size: item.file_size || 0, + status: item.status, + created_at: item.created_at + })); + allData = [...allData, ...convertedAttachments]; + } } - return { data: extractedData }; + return { data: allData }; } catch (error) { console.error('获取文档状态失败:', error); return { diff --git a/app/components/layout/Sidebar.tsx b/app/components/layout/Sidebar.tsx index 0ffb26a..5774b72 100644 --- a/app/components/layout/Sidebar.tsx +++ b/app/components/layout/Sidebar.tsx @@ -192,6 +192,12 @@ export function Sidebar({ onToggle, collapsed, userRole, selectedApp = '' }: Sid path: '/rules-files', icon: 'ri-list-check-2' }, + { + id: 'cross-checking', + title: '交叉评查', + path: '/cross-checking', + icon: 'ri-color-filter-line' + }, // { // id: 'rule-new', // title: '新增评查点', diff --git a/app/components/reviews/Comparison.tsx b/app/components/reviews/Comparison.tsx new file mode 100644 index 0000000..c1b6a44 --- /dev/null +++ b/app/components/reviews/Comparison.tsx @@ -0,0 +1,311 @@ +import { useState, useEffect } from "react"; + +// 定义字段比对结果类型 +interface FieldComparison { + field: string; + status: string; + details: string; + source_page: string; + template_page: string; +} + +// 定义比对结果类型 +interface ComparisonResults { + [sectionName: string]: FieldComparison[]; +} + +// 定义比对文档类型 +interface ComparisonDocument { + comparison_results?: ComparisonResults; + [key: string]: unknown; +} + +// 定义组件Props类型 +interface ComparisonProps { + comparison_document: ComparisonDocument | null; + onPageJump?: (sourcePage: number, templatePage: number) => void; +} + +// 筛选类型 +type FilterType = 'all' | 'normal' | 'abnormal'; + +export function Comparison({ comparison_document, onPageJump }: ComparisonProps) { + const [expandedSections, setExpandedSections] = useState>(new Set()); + const [filterType, setFilterType] = useState('all'); + + // 默认展开所有章节 + useEffect(() => { + if (comparison_document?.comparison_results) { + const allSections = Object.keys(comparison_document.comparison_results); + setExpandedSections(new Set(allSections)); + } + }, [comparison_document?.comparison_results]); + + // 如果没有比对文档,显示暂无数据 + if (!comparison_document || !comparison_document.comparison_results) { + return ( +
+
+ + 结构比对(0) +
+
+ +

暂无结构比对数据

+
+
+ ); + } + + const comparisonResults = comparison_document.comparison_results; + + // 确保 comparisonResults 的所有值都是数组,过滤掉非数组项 + const validComparisonResults: ComparisonResults = {}; + if (comparisonResults) { + Object.entries(comparisonResults).forEach(([key, value]) => { + if (Array.isArray(value)) { + validComparisonResults[key] = value; + } + }); + } + + // 切换章节展开状态 + const toggleSection = (sectionName: string) => { + const newExpanded = new Set(expandedSections); + if (newExpanded.has(sectionName)) { + newExpanded.delete(sectionName); + } else { + newExpanded.add(sectionName); + } + setExpandedSections(newExpanded); + }; + + // 处理整个字段框的点击,同时传递两个页码 + const handleFieldClick = (field: FieldComparison) => { + if (onPageJump) { + const sourcePage = field.source_page ? parseInt(field.source_page) : 0; + const templatePage = field.template_page ? parseInt(field.template_page) : 0; + onPageJump(sourcePage, templatePage); + } + }; + + // 处理键盘事件 + const handleFieldKeyDown = (event: React.KeyboardEvent, field: FieldComparison) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleFieldClick(field); + } + }; + + // 获取状态样式 + const getStatusStyle = (status: string) => { + if (status === 'normal') { + return { + icon: 'ri-check-circle-line', + color: 'text-green-500', + bgColor: 'bg-green-50', + borderColor: 'border-green-200' + }; + } else { + return { + icon: 'ri-alert-circle-line', + color: 'text-red-500', + bgColor: 'bg-red-50', + borderColor: 'border-red-200' + }; + } + }; + + // 统计总体信息 + const totalFields = Object.values(validComparisonResults).flat().length; + const normalFields = Object.values(validComparisonResults).flat().filter(field => field.status === 'normal').length; + const abnormalFields = totalFields - normalFields; + + // 根据筛选类型过滤数据 + const getFilteredResults = () => { + if (filterType === 'all') { + return validComparisonResults; + } + + const filteredResults: ComparisonResults = {}; + + Object.entries(validComparisonResults).forEach(([sectionName, fields]) => { + let filteredFields: FieldComparison[] = []; + + if (filterType === 'normal') { + filteredFields = fields.filter(field => field.status === 'normal'); + } else if (filterType === 'abnormal') { + filteredFields = fields.filter(field => field.status !== 'normal'); + } + + if (filteredFields.length > 0) { + filteredResults[sectionName] = filteredFields; + } + }); + + return filteredResults; + }; + + const filteredResults = getFilteredResults(); + + return ( +
+ {/* 固定头部 */} +
+
+ + 结构比对({totalFields}) +
+ + {/* 固定统计概览和筛选按钮 */} +
+ {/* 统计概览 */} +
+ + + +
+ + {/* 筛选重置按钮 */} + {filterType !== 'all' && ( + + )} +
+
+ + {/* 滚动内容区域 */} +
+ {Object.keys(filteredResults).length === 0 ? ( +
+ +

没有找到{filterType === 'normal' ? '正常' : '异常'}的字段

+
+ ) : ( + Object.entries(filteredResults).map(([sectionName, fields]) => { + const isExpanded = expandedSections.has(sectionName); + const sectionAbnormalCount = fields.filter(field => field.status !== 'normal').length; + + return ( +
+ {/* 章节头部 */} + + + {/* 章节内容 */} + {isExpanded && ( +
+ {fields.map((field, index) => { + const statusStyle = getStatusStyle(field.status); + + return ( +
handleFieldClick(field)} + onKeyDown={(e) => handleFieldKeyDown(e, field)} + className={`text-sm mb-3 last:mb-0 p-2 rounded-lg border cursor-pointer transition-all duration-200 ${statusStyle.bgColor} ${statusStyle.borderColor} hover:shadow-md hover:scale-[1.02] hover:border-opacity-80 + focus:outline-none focus:ring-1 ${field.status === 'normal' ? 'focus:ring-green-700' : 'focus:ring-red-700'} focus:ring-opacity-50 ${field.status === 'normal' ? 'border-green-200' : 'border-red-200'}`} + > + {/* 字段名和状态 */} +
+
+ {field.field} +
+ + {field.status === 'normal' ? '正常' : '异常'} + +
+ + {/* 详细说明 */} +

{field.details}

+ + {/* 页码信息 */} +
+ {field.source_page ? ( +
+ + 主文件: 第{field.source_page}页 +
+ ) : ( +
+ + 主文件: 未找到内容 +
+ )} + + {field.template_page ? ( +
+ + 模板: 第{field.template_page}页 +
+ ) : ( +
+ + 模板: 未找到内容 +
+ )} +
+
+ ); + })} +
+ )} +
+ ); + }) + )} +
+ + {/* 底部说明 */} + {/*
+
+ + 点击字段框可同时跳转到主文件和模板对应页面 +
+
*/} +
+ ); +} \ No newline at end of file diff --git a/app/components/reviews/FilePreview.tsx b/app/components/reviews/FilePreview.tsx index 94222d7..94d40db 100644 --- a/app/components/reviews/FilePreview.tsx +++ b/app/components/reviews/FilePreview.tsx @@ -63,6 +63,7 @@ interface FileContent { title: string; content: string; }[]; + template_contract_path?: string; } interface FilePreviewProps { @@ -183,7 +184,8 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage // toastService.success(`已跳转至目标页码`); } // 如果有目标页码,并且与上次不同或activeReviewPointId变化了,则执行跳转 - if (targetPage && numPages && targetPage <= numPages && (targetPage !== prevTargetPageRef.current || activeReviewPointResultId)) { + if (targetPage && numPages && targetPage <= numPages) { + // if (targetPage && numPages && targetPage <= numPages && (targetPage !== prevTargetPageRef.current || activeReviewPointResultId)) { prevTargetPageRef.current = targetPage; let newTargetPage = targetPage; @@ -375,8 +377,17 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage // 渲染文档内容 const renderDocumentContent = () => { + const real_path = fileContent.path || fileContent.template_contract_path || ''; + // 如果路径无效,显示错误信息 - if (!fileContent.path) { + if (!real_path) { + if(!fileContent.template_contract_path){ + return ( +
+

无法加载文件:合同模板未上传

+
+ ); + } return (

无法加载文件:路径无效

@@ -384,8 +395,9 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage ); } + // console.log('real_path',real_path); // 获取文件扩展名 - const fileExtension = fileContent.path.split('.').pop()?.toLowerCase(); + const fileExtension = real_path.split('.').pop()?.toLowerCase(); // PDF内容渲染 const renderPdfContent = () => ( @@ -398,7 +410,7 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage }} > { console.error("PDF加载错误:", error); @@ -457,39 +469,41 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage return (
-
-
- - {isStructuredView ? '附件预览' : '文件预览'} +
+
+ + + {isStructuredView ? '模板预览' : '文件预览'} +
-
+
{/* 页码跳转控件 */} -
+
- {numPages && / {numPages}} + {numPages && ( + + / {numPages} + + )}
- {"比例:"+zoomLevel+"%"} + + 比例:{zoomLevel}% +
diff --git a/app/components/reviews/ReviewTabs.tsx b/app/components/reviews/ReviewTabs.tsx index 4462b77..8686bd7 100644 --- a/app/components/reviews/ReviewTabs.tsx +++ b/app/components/reviews/ReviewTabs.tsx @@ -2,16 +2,22 @@ * 评查选项卡组件 * 提供三个选项卡:评查结果、AI智能分析、文件信息 */ -import { ReactNode, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { ReactNode, useState, useRef } from 'react'; +import { useNavigate, useRevalidator } from 'react-router-dom'; import { loadingBarService } from '~/components/ui/LoadingBar'; +import { Modal } from '~/components/ui/Modal'; +import { UploadArea, type UploadAreaRef } from '~/components/ui/UploadArea'; +import { Button } from '~/components/ui/Button'; +import { toastService } from '~/components/ui/Toast'; import { DOCUMENT_URL } from "~/api/axios-client"; +import { uploadFileToBinary, uploadDocumentToServer } from '~/api/files/files-upload'; interface ReviewTabsProps { activeTab: string; onTabChange: (tabKey: string) => void; children: ReactNode; fileInfo: { + id?: number; previousRoute?: string; path?: string; auditStatus?: number; @@ -22,7 +28,12 @@ interface ReviewTabsProps { export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfirmResults }: ReviewTabsProps) { const [isNavigating, setIsNavigating] = useState(false); + const [isReuploadModalOpen, setIsReuploadModalOpen] = useState(false); + const [selectedTemplateFiles, setSelectedTemplateFiles] = useState([]); + const [isUploading, setIsUploading] = useState(false); + const uploadAreaRef = useRef(null); const navigate = useNavigate(); + const revalidator = useRevalidator(); // 返回上一级 const handleBack = () => { @@ -40,9 +51,10 @@ export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfi : previousRoute === 'filesUpload' ? "/files/upload" : "/rules-files"; - - // 立即导航返回 - navigate(returnTo); + // 立即导航返回 + navigate(returnTo); + // 触发上级页面数据重新加载 + revalidator.revalidate(); }; // 下载原文件 @@ -83,6 +95,127 @@ export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfi } }; + // 打开重新上传模板模态框 + const handleOpenReuploadModal = () => { + setIsReuploadModalOpen(true); + setSelectedTemplateFiles([]); + }; + + // 关闭重新上传模板模态框 + const handleCloseReuploadModal = () => { + setIsReuploadModalOpen(false); + setSelectedTemplateFiles([]); + // 重置文件输入 + if (uploadAreaRef.current) { + uploadAreaRef.current.resetFileInput(); + } + }; + + // 处理模板文件选择 + const handleTemplateFilesSelected = (files: FileList) => { + try { + if (files.length > 0) { + // 验证文件类型,只允许PDF文件 + const validFiles: File[] = []; + let hasInvalidFiles = false; + + Array.from(files).forEach(file => { + if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) { + validFiles.push(file); + } else { + hasInvalidFiles = true; + console.error(`无效的文件类型: ${file.name}, 类型: ${file.type}`); + } + }); + + if (hasInvalidFiles) { + toastService.error('只能上传PDF格式的文件'); + } + + if (validFiles.length > 0) { + setSelectedTemplateFiles(validFiles); + } + } + } catch (error) { + console.error('处理模板文件选择时发生错误:', error); + toastService.error('文件选择失败,请重试'); + } + }; + + + // 确认上传模板文件 + const handleConfirmUpload = async () => { + if (selectedTemplateFiles.length === 0) { + toastService.error('请先选择要上传的模板文件'); + return; + } + + try { + setIsUploading(true); + + // 这里可以调用上传API + let binaryData: ArrayBuffer; + try { + binaryData = await uploadFileToBinary(selectedTemplateFiles[0]); + } catch (error) { + console.error('上传文件失败:', error); + throw new Error(`文件 ${selectedTemplateFiles[0].name} 转换失败: ${error instanceof Error ? error.message : '未知错误'}`); + } + + // const uploadInfo = { + // binaryData, + // fileName: selectedTemplateFiles[0].name, + // fileType: 'pdf', + // documentType: '1', + // priority: 'normal', + // documentNumber: null, + // remark: null, + // isTestDocument: false, + // documentId: fileInfo + // }; + // console.log('uploadInfo',uploadInfo); + + const uploadResult = await uploadDocumentToServer( + binaryData, + selectedTemplateFiles[0].name, + 'pdf', //file_type 文件类型:pdf + '1', //fileType(type_id) 合同id:1 + 'normal', //priority 优先级:normal + null, //document_number 文档编号 + null, //remark 备注 + false, //is_test_document 是否为测试文档:false + fileInfo.id, //document_id 主文档id + true //is_reupload 是否为重新上传:true + ); + // console.log('重新上传合同模板',uploadResult); + + if (uploadResult.error) { + throw new Error(uploadResult.error); + } + + toastService.success('模板文件上传成功,结构比对数据将会发生更新,即将返回上一页...'); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + handleCloseReuploadModal(); + handleBack(); + + } catch (error) { + console.error('上传模板文件失败:', error); + toastService.error(`上传失败: ${error instanceof Error ? error.message : '未知错误'}`); + } finally { + setIsUploading(false); + } + }; + + // 格式化文件大小 + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; return (
@@ -105,9 +238,7 @@ export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfi > AI智能分析 */} - {/* {fileInfo.type === '1' && ( */} - {/* 隐藏结构比对 */} - {fileInfo.type === '999999' && ( + {fileInfo.type === '1' && (
{/* 操作按钮 */}
+ {/* 重新上传 */} + {activeTab === 'filecompare' && ( + + )} {/* 返回上一级 */}
+ + {/* 重新上传模板模态框 */} + + + +
+ } + > +
+
+

请选择新的模板文件用于结构比对。

+

+ + 注意:只支持PDF格式的文件,上传后将替换当前的比对模板。 +

+
+ + + 支持格式:PDF + + } + disabled={isUploading} + /> + + {/* 已选择的文件列表 */} + {selectedTemplateFiles.length > 0 && ( +
+

已选择的文件:

+
+ {selectedTemplateFiles.map((file, index) => ( +
+
+ +
+
+ {file.name} +
+
+ {formatFileSize(file.size)} +
+
+
+ +
+ ))} +
+
+ )} +
+
); } \ No newline at end of file diff --git a/app/components/reviews/index.ts b/app/components/reviews/index.ts index a444506..af679ec 100644 --- a/app/components/reviews/index.ts +++ b/app/components/reviews/index.ts @@ -8,4 +8,5 @@ export { FilePreview } from './FilePreview'; export { ReviewPointsList } from './ReviewPointsList'; export type { ReviewPoint } from './ReviewPointsList'; export { AIAnalysis } from './AIAnalysis'; -export { FileDetails } from './FileDetails'; \ No newline at end of file +export { FileDetails } from './FileDetails'; +export { Comparison } from './Comparison'; \ No newline at end of file diff --git a/app/config/api-config.ts b/app/config/api-config.ts index 22249af..8d51bc0 100644 --- a/app/config/api-config.ts +++ b/app/config/api-config.ts @@ -12,15 +12,40 @@ interface ApiConfig { documentUrl: string; // 文档上传API URL uploadUrl: string; + // OAuth2.0配置 + oauth: { + // IDaaS服务器地址 + serverUrl: string; + // OAuth2应用Client ID + clientId: string; + // OAuth2应用Client Secret + clientSecret: string; + // 回调地址 + redirectUri: string; + // 应用ID(用于登出) + appId: string; + }; } // 不同环境的默认配置 +// 由于合同模板的上传,后续的的uploadUrl都不需要/upload,直接写/admin/documents,由程序自动添加/upload或/upload_contract_template const configs: Record = { // 开发环境 development: { + // baseUrl: 'http://172.16.0.55:8008', + // baseUrl: 'http://172.16.0.81:3000', baseUrl: 'http://nas.7bm.co:3000', - documentUrl: 'http://nas.7bm.co:9000/docauditai/', - uploadUrl: 'http://172.16.0.58:8008/admin/documents/upload', + documentUrl: 'http://172.16.0.81:9000/docauditai/', + uploadUrl: 'http://172.16.0.55:8008/admin/documents', + // uploadUrl: 'http://172.16.0.58:8008/admin/documents', + // uploadUrl: 'http://172.16.0.58:8008/admin/documents', + oauth: { + serverUrl: 'http://10.79.112.85', // IDaaS服务器地址 + clientId: '54d2a619fe5c81ae1250434c441fccccqMtKwh7H4fO', // 需要替换为实际的Client ID + clientSecret: 'your_client_secret', // 需要替换为实际的Client Secret + redirectUri: 'http://localhost:3000/callback', // 回调地址 + appId: 'idaasoauth2' // 应用ID,用于登出 + } }, // 测试环境 @@ -28,6 +53,13 @@ const configs: Record = { baseUrl: 'http://nas.7bm.co:3000', documentUrl: 'http://nas.7bm.co:9000/docauditai/', uploadUrl: 'http://172.16.0.58:8008/admin/documents/upload', + oauth: { + serverUrl: 'http://10.79.112.85', // IDaaS服务器地址 + clientId: '54d2a619fe5c81ae1250434c441fccccqMtKwh7H4fO', // 需要替换为实际的Client ID + clientSecret: 'your_client_secret', // 需要替换为实际的Client Secret + redirectUri: 'http://nas.7bm.co:3000/callback', // 回调地址 + appId: 'idaasoauth2' // 应用ID,用于登出 + } }, // 生产环境 @@ -38,6 +70,13 @@ const configs: Record = { documentUrl: 'http://10.76.244.156:9000/docauditai/', // 文件上传 uploadUrl: 'http://10.79.97.16:8000/admin/documents/upload', + oauth: { + serverUrl: 'http://10.79.112.85', // IDaaS服务器地址 + clientId: '54d2a619fe5c81ae1250434c441fccccqMtKwh7H4fO', // 需要替换为实际的Client ID + clientSecret: 'your_client_secret', // 需要替换为实际的Client Secret + redirectUri: 'http://10.79.97.17/callback', // 回调地址 + appId: 'idaasoauth2' // 应用ID,用于登出 + } }, // 备用配置 (可以根据需要添加更多环境) @@ -45,6 +84,13 @@ const configs: Record = { baseUrl: 'http://172.16.0.119:9000/admin', documentUrl: 'http://nas.7bm.co:9000/docauditai/', uploadUrl: 'http://172.16.0.119:8000/admin/documents/upload', + oauth: { + serverUrl: 'http://10.79.112.85', // IDaaS服务器地址 + clientId: '54d2a619fe5c81ae1250434c441fccccqMtKwh7H4fO', // 需要替换为实际的Client ID + clientSecret: 'your_client_secret', // 需要替换为实际的Client Secret + redirectUri: 'http://172.16.0.119:3000/callback', // 回调地址 + appId: 'idaasoauth2' // 应用ID,用于登出 + } } }; @@ -59,7 +105,14 @@ const getConfigFromEnv = (defaultConfig: ApiConfig): ApiConfig => { return { baseUrl: process.env.NEXT_PUBLIC_API_BASE_URL || defaultConfig.baseUrl, documentUrl: process.env.NEXT_PUBLIC_DOCUMENT_URL || defaultConfig.documentUrl, - uploadUrl: process.env.NEXT_PUBLIC_UPLOAD_URL || defaultConfig.uploadUrl + uploadUrl: process.env.NEXT_PUBLIC_UPLOAD_URL || defaultConfig.uploadUrl, + oauth: { + serverUrl: process.env.NEXT_PUBLIC_OAUTH_SERVER_URL || defaultConfig.oauth.serverUrl, + clientId: process.env.NEXT_PUBLIC_OAUTH_CLIENT_ID || defaultConfig.oauth.clientId, + clientSecret: process.env.NEXT_PUBLIC_OAUTH_CLIENT_SECRET || defaultConfig.oauth.clientSecret, + redirectUri: process.env.NEXT_PUBLIC_OAUTH_REDIRECT_URI || defaultConfig.oauth.redirectUri, + appId: process.env.NEXT_PUBLIC_OAUTH_APP_ID || defaultConfig.oauth.appId + } }; }; @@ -83,7 +136,8 @@ export const apiConfig = getCurrentConfig(); export const { baseUrl: API_BASE_URL, documentUrl: DOCUMENT_URL, - uploadUrl: UPLOAD_URL + uploadUrl: UPLOAD_URL, + oauth: OAUTH_CONFIG } = apiConfig; // 导出所有配置,供调试使用 diff --git a/app/root.tsx b/app/root.tsx index 7d97f66..2d41d96 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -67,16 +67,34 @@ export async function getUserSession(request: Request) { const session = await getSession(request); const isAuthenticated = session.get("isAuthenticated") === true; const userRole = session.get("userRole") || 'common' as UserRole; + const accessToken = session.get("accessToken"); + const refreshToken = session.get("refreshToken"); + const tokenIssuedAt = session.get("tokenIssuedAt"); + const tokenExpiresIn = session.get("tokenExpiresIn"); + const userInfo = session.get("userInfo"); + + // 检查token是否过期 + let isTokenExpired = false; + if (accessToken && tokenIssuedAt && tokenExpiresIn) { + const now = Date.now(); + const expiresAt = tokenIssuedAt + (tokenExpiresIn * 1000); + isTokenExpired = now >= expiresAt; + } // console.log("获取会话状态:", // // "Cookie:", request.headers.get("Cookie"), // "是否认证:", isAuthenticated, - // "用户角色:", userRole + // "用户角色:", userRole, + // "Token过期:", isTokenExpired // ); return { - isAuthenticated, - userRole + isAuthenticated: isAuthenticated && !isTokenExpired, + userRole, + accessToken, + refreshToken, + userInfo, + isTokenExpired }; } diff --git a/app/routes/callback.tsx b/app/routes/callback.tsx new file mode 100644 index 0000000..8e82ab0 --- /dev/null +++ b/app/routes/callback.tsx @@ -0,0 +1,89 @@ +import { type LoaderFunctionArgs, redirect } from "@remix-run/node"; +import { OAuthClient } from "~/utils/oauth-client"; +import { OAUTH_CONFIG } from "~/config/api-config"; +import { sessionStorage } from "~/root"; + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + const error_description = url.searchParams.get("error_description"); + + // 检查是否有错误 + if (error) { + console.error("OAuth2.0授权失败:", error, error_description); + return redirect(`/login?error=${encodeURIComponent(error_description || error)}`); + } + + // 检查是否有授权码 + if (!code) { + console.error("OAuth2.0回调缺少授权码"); + return redirect("/login?error=missing_code"); + } + + // 验证状态值(可选,但建议实现) + // 这里简单验证state是否以_idp结尾 + if (!state || !state.endsWith("_idp")) { + console.error("OAuth2.0状态值验证失败"); + return redirect("/login?error=invalid_state"); + } + + try { + // 创建OAuth客户端 + const oauthClient = new OAuthClient(OAUTH_CONFIG); + + // 获取访问令牌 + const tokenResponse = await oauthClient.getAccessToken(code); + if (!tokenResponse) { + console.error("获取访问令牌失败"); + return redirect("/login?error=token_error"); + } + + // 获取用户信息 + const userInfo = await oauthClient.getUserInfo(tokenResponse.access_token); + if (!userInfo || !userInfo.success) { + console.error("获取用户信息失败:", userInfo); + return redirect("/login?error=userinfo_error"); + } + + // 创建会话 + const session = await sessionStorage.getSession(); + session.set("isAuthenticated", true); + session.set("accessToken", tokenResponse.access_token); + session.set("refreshToken", tokenResponse.refresh_token); + session.set("tokenIssuedAt", Date.now()); + session.set("tokenExpiresIn", tokenResponse.expires_in); + session.set("userInfo", userInfo.data); + + // 根据用户信息判断用户角色,这里可以根据实际业务逻辑调整 + const userRole = userInfo.data.username === "admin" ? "developer" : "common"; + session.set("userRole", userRole); + + // 获取重定向URL + const redirectTo = url.searchParams.get("redirect") || "/"; + + const cookie = await sessionStorage.commitSession(session); + + return redirect(redirectTo, { + headers: { + "Set-Cookie": cookie + } + }); + + } catch (error) { + console.error("OAuth2.0回调处理失败:", error); + return redirect("/login?error=callback_error"); + } +} + +export default function Callback() { + return ( +
+
+
+

正在处理登录...

+
+
+ ); +} \ No newline at end of file diff --git a/app/routes/cross-checking._index.tsx b/app/routes/cross-checking._index.tsx new file mode 100644 index 0000000..7fceb16 --- /dev/null +++ b/app/routes/cross-checking._index.tsx @@ -0,0 +1,26 @@ +import {type MetaFunction} from "@remix-run/node"; + +export const meta: MetaFunction = () => { + return [ + {title: "交叉评查 - 中国烟草AI合同及卷宗审核系统"}, + {name: "cross-checking", content: "交叉评查"} + ] +} + +// export const loader = async ({ request }: LoaderFunctionArgs) => { +// const { user } = await requireUser(request); +// return json({ user }); +// } + +// export const action = async ({ request }: ActionFunctionArgs) => { +// const { user } = await requireUser(request); +// return json({ user }); +// } + +export default function CrossCheckingIndex() { + return ( +
+

交叉评查

+
+ ) +} \ No newline at end of file diff --git a/app/routes/cross-checking.tsx b/app/routes/cross-checking.tsx new file mode 100644 index 0000000..9e97173 --- /dev/null +++ b/app/routes/cross-checking.tsx @@ -0,0 +1,22 @@ +import { Outlet } from "react-router-dom"; +import {type MetaFunction} from "@remix-run/node"; + +export const meta: MetaFunction = () => { + return [ + {title: "交叉评查 - 中国烟草AI合同及卷宗审核系统"}, + {name: "cross-checking", content: "交叉评查"} + ] +} + +export const handle = { + breadcrumb: "交叉评查" +} + +/** + * 交叉评查路由布局 + */ +export default function CrossCheckingLayout() { + return ( + + ) +} diff --git a/app/routes/documents._index.tsx b/app/routes/documents._index.tsx index 8289207..682b33b 100644 --- a/app/routes/documents._index.tsx +++ b/app/routes/documents._index.tsx @@ -643,7 +643,7 @@ export default function DocumentsIndex() { // 检查audit_status是否为0,如果是则更新为2 if (auditStatus === 0 || auditStatus === null) { try { - + // console.log('开始审核',fileId,auditStatus) const response = await updateDocumentAuditStatus(fileId.toString(), 2); if (response.error) { console.error('更新文件审核状态失败:', response.error); @@ -656,7 +656,7 @@ export default function DocumentsIndex() { return; } } - + // console.log('更新成功,开始跳转') // 导航到评查详情页 navigate(`/reviews?id=${fileId}&previousRoute=documents`); }; diff --git a/app/routes/files.upload.tsx b/app/routes/files.upload.tsx index bd081ca..bec42f9 100644 --- a/app/routes/files.upload.tsx +++ b/app/routes/files.upload.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from "react"; import { MetaFunction, ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; -import { Form, useActionData, useLoaderData, useNavigate, useBlocker } from "@remix-run/react"; +import { Form, useActionData, useLoaderData, useNavigate } from "@remix-run/react"; import { Card } from "~/components/ui/Card"; import { Button } from "~/components/ui/Button"; import { Table } from "~/components/ui/Table"; @@ -121,49 +121,10 @@ async function handleFileUpload( priority: Priority, documentNumber: string | null, remark: string | null, - isTestDocument: boolean + isTestDocument: boolean, + documentId?: number | null, + isReupload: boolean = false ): Promise { - // try { - // // 使用封装的上传函数 - // const response = await uploadDocumentToServer( - // binaryData, - // fileName, - // fileType, - // documentType, - // PRIORITY_TO_CHINESE[priority], - // documentNumber, - // remark, - // isTestDocument - // ); - - // if (response.error) { - // console.error('[API] 上传错误:', response.error); - // return { - // success: false, - // error: response.error - // }; - // } - - // // 确保返回有效的FileUploadResponse对象 - // // console.log('上传成功:', response.data); - // if (response.data) { - // return response.data; - // } - - // // 如果没有数据,则返回错误 - // // console.log('上传失败:', response.error); - // return { - // success: false, - // error: '上传失败,未获取到响应数据' - // }; - // } catch (error) { - // console.error('[API] 上传错误:', error); - // return { - // success: false, - // error: error instanceof Error ? error.message : '上传失败' - // }; - // } - const response = await uploadDocumentToServer( binaryData, fileName, @@ -172,7 +133,9 @@ async function handleFileUpload( priority, documentNumber, remark, - isTestDocument + isTestDocument, + documentId, + isReupload ); if (response.error || !response.data) { @@ -309,9 +272,9 @@ export default function FilesUpload() { // 合同文件上传状态 // 这些变量暂时未使用,但保留以备将来扩展 - // const [isContractType, setIsContractType] = useState(false); - // const [contractMainFiles, setContractMainFiles] = useState([]); - // const [contractAttachmentFiles, setContractAttachmentFiles] = useState([]); + const [isContractType, setIsContractType] = useState(false); + const [contractMainFiles, setContractMainFiles] = useState([]); + const [contractAttachmentFiles, setContractAttachmentFiles] = useState([]); const [uploadProgress, setUploadProgress] = useState(0); const [uploadSpeed, setUploadSpeed] = useState("0KB/s"); @@ -336,13 +299,13 @@ export default function FilesUpload() { if (typeof window !== 'undefined') { const storedReviewType = sessionStorage.getItem('reviewType'); setReviewType(storedReviewType); - // 根据 reviewType 过滤文档类型和文档列表 filterDocumentTypes(storedReviewType, loaderData.documentTypes); filterDocuments(storedReviewType); // 如果reviewType是contract,自动选择合同文档类型 if (storedReviewType === 'contract') { + setIsContractType(true); // 查找ID为1的合同文档类型 const contractType = loaderData.documentTypes.find(type => type.id === 1); if (contractType) { @@ -385,7 +348,11 @@ export default function FilesUpload() { const filterDocuments = async (reviewType: string | null) => { if (!reviewType) { // 如果没有特定的 reviewType,使用原始数据 - setQueueFiles(loaderData.documents); + const documents = loaderData.documents; + setQueueFiles(documents); + + // 启动状态检查定时器 + startStatusChecker(documents); return; } @@ -399,12 +366,20 @@ export default function FilesUpload() { setQueueFiles(loaderData.documents); return; } + const documents = response.data || []; + console.log('过滤文档列表成功:', documents); + setQueueFiles(documents); - setQueueFiles(response.data || []); + // 数据加载完成后立即启动状态检查定时器 + startStatusChecker(documents); } catch (error) { console.error('过滤文档列表失败:', error); // 出错时使用原始数据 - setQueueFiles(loaderData.documents); + const documents = loaderData.documents; + setQueueFiles(documents); + + // 即使出错也启动状态检查定时器 + startStatusChecker(documents); } }; @@ -424,8 +399,10 @@ export default function FilesUpload() { // 上传完成后的文件信息列表 const [completedFiles, setCompletedFiles] = useState([]); - // 计时器引用 - const progressIntervalRef = useRef(null); + // 计时器引用 - 分离为三个独立的定时器 + const uploadProgressIntervalRef = useRef(null); + const processingStatusIntervalRef = useRef(null); + const queueStatusIntervalRef = useRef(null); // 原 statusCheckIntervalRef // UploadArea组件引用 const uploadAreaRef = useRef(null); @@ -451,69 +428,122 @@ export default function FilesUpload() { } }, [actionData]); - // 状态检查定时器引用 - const statusCheckIntervalRef = useRef(null); - // 添加组件挂载状态引用 const isMountedRef = useRef(true); // useEffect 处理上传队列状态检查定时器 - 只在组件卸载时清除 useEffect(() => { - // console.log('设置上传队列状态检查定时器'); + console.log('设置上传队列状态检查定时器'); // 标记组件已挂载 isMountedRef.current = true; - // 设置定时器检查队列中文件的状态,初始先加载一次查询 - checkQueueStatus(); - statusCheckIntervalRef.current = setInterval(checkQueueStatus, 10000); - // 只在组件卸载时清除 return () => { // console.log('组件卸载,清除上传队列状态检查定时器'); // 标记组件已卸载 isMountedRef.current = false; - if (statusCheckIntervalRef.current) { - clearInterval(statusCheckIntervalRef.current); - statusCheckIntervalRef.current = null; + if (queueStatusIntervalRef.current) { + clearInterval(queueStatusIntervalRef.current); + queueStatusIntervalRef.current = null; } }; }, []); - // 检查队列中未完成文档的状态 - const checkQueueStatus = async () => { + // 启动状态检查定时器的函数 + const startStatusChecker = (files: Document[]) => { + console.log('启动状态检查定时器,队列文件数量:', files.length); + + // 清除之前的定时器 + if (queueStatusIntervalRef.current) { + clearInterval(queueStatusIntervalRef.current); + } + + // 只有当有文件时才启动定时器 + if (files.length > 0) { + // 立即检查一次 + checkQueueStatusWithFiles(files); + + // 启动定时器 + queueStatusIntervalRef.current = setInterval(() => { + if (isMountedRef.current) { + // 获取最新的queueFiles状态 + setQueueFiles(currentFiles => { + checkQueueStatusWithFiles(currentFiles); + return currentFiles; // 不改变状态,只是为了获取最新值 + }); + } + }, 10000); + } + }; + + // 检查指定文件列表的状态 + const checkQueueStatusWithFiles = async (files: Document[]) => { try { - // console.log('开始检查队列状态,当前队列文件:', queueFiles); + // console.log('开始检查队列状态,当前队列文件:', files); - // 获取所有未完成的文档ID - const incompleteIds = queueFiles - .filter(file => file.status !== DocumentStatus.PROCESSED && file.id) - .map(file => file.id); + // 直接从sessionStorage读取reviewType,避免异步状态更新问题 + const currentReviewType = typeof window !== 'undefined' ? sessionStorage.getItem('reviewType') : null; + // console.log('从sessionStorage读取的reviewType:', currentReviewType); - // console.log('未完成的文档ID:', incompleteIds); + // 获取所有未完成的文档 + const incompleteFiles = files.filter(file => + file.status !== DocumentStatus.PROCESSED && file.id + ); - if (incompleteIds.length === 0) { - // console.log('没有未完成的文档,跳过状态检查'); + if (incompleteFiles.length === 0) { + console.log('没有未完成的文档,跳过状态检查'); return; } - // 获取这些文档的最新状态 - const statusResponse = await getDocumentsStatus(incompleteIds); + let statusResponse; + + // 如果是合同类型,需要分类处理 + console.log('当前reviewType:', currentReviewType); + if (currentReviewType === 'contract') { + // 分类文档ID + const mainDocumentIds: number[] = []; + const attachmentIds: number[] = []; + + incompleteFiles.forEach(file => { + // 检查是否存在template_contract_path属性来判断是否为合同附件 + if ('template_contract_path' in file && file.template_contract_path) { + attachmentIds.push(file.id); + } else { + mainDocumentIds.push(file.id); + } + }); + + console.log('合同主文件ID:', mainDocumentIds); + console.log('合同附件ID:', attachmentIds); + + // 分别查询状态 + statusResponse = await getDocumentsStatus(mainDocumentIds, attachmentIds); + } else { + // 非合同类型,使用原有逻辑 + const incompleteIds = incompleteFiles.map(file => file.id); + // console.log('未完成的文档ID:', incompleteIds); + statusResponse = await getDocumentsStatus(incompleteIds); + } + // console.log('状态检查响应:', statusResponse); if (statusResponse.data) { - // 更新队列中的文档状态 + // 更新队列中的文档状态,使用批量更新避免频繁渲染 setQueueFiles(prevFiles => { + let hasChanges = false; const updatedFiles = prevFiles.map(file => { const updatedStatus = statusResponse.data.find(doc => doc.id === file.id); - if (updatedStatus) { - // console.log(`文档 ${file.id} 状态更新: ${file.status} -> ${updatedStatus.status}`); + if (updatedStatus && updatedStatus.status !== file.status) { + console.log(`文档 ${file.id} 状态更新: ${file.status} -> ${updatedStatus.status}`); + hasChanges = true; return { ...file, status: updatedStatus.status }; } return file; }); - // console.log('更新后的队列文件:', updatedFiles); - return updatedFiles; + + // 只有在确实有变化时才返回新数组 + return hasChanges ? updatedFiles : prevFiles; }); } } catch (error) { @@ -574,11 +604,11 @@ export default function FilesUpload() { // currentFiles: currentFiles.length // }); - // setIsContractType(isContract); + setIsContractType(isContract); // 重置文件状态 - // setContractMainFiles([]); - // setContractAttachmentFiles([]); + setContractMainFiles([]); + setContractAttachmentFiles([]); setCurrentFiles([]); // 如果已经有选中的文件,且选择了文件类型,且不是合同类型,则开始上传 @@ -593,14 +623,13 @@ export default function FilesUpload() { } else { setFileType(""); - // setIsContractType(false); + setIsContractType(false); // 如果用户选择了空选项,显示错误信息 setFileTypeError("上传文件之前请选择文件类型"); } }; - // 处理合同主文件选择 - 暂时未使用,保留以备将来扩展 - /* + // 处理合同主文件选择 const handleContractMainFilesSelected = (files: FileList) => { try { // console.log('【调试-handleContractMainFilesSelected】开始处理合同主文件选择, 文件数量:', files.length); @@ -639,7 +668,7 @@ export default function FilesUpload() { // console.log('【调试-handleContractMainFilesSelected】有效文件数量:', validFiles.length); // console.log('【调试-handleContractMainFilesSelected】有效文件:', validFiles.map(f => ({ name: f.name, size: f.size, type: f.type }))); - // setContractMainFiles(validFiles); + setContractMainFiles(validFiles); } else { console.error('【调试-handleContractMainFilesSelected】没有有效的PDF文件或组件已卸载'); } @@ -650,10 +679,8 @@ export default function FilesUpload() { console.error('【调试-handleContractMainFilesSelected】处理合同主文件选择时发生错误:', error); } }; - */ - // 处理合同附件选择 - 暂时未使用,保留以备将来扩展 - /* + // 处理合同附件选择 const handleContractAttachmentFilesSelected = (files: FileList) => { try { // console.log('【调试-handleContractAttachmentFilesSelected】开始处理合同附件选择, 文件数量:', files.length); @@ -692,7 +719,7 @@ export default function FilesUpload() { // console.log('【调试-handleContractAttachmentFilesSelected】有效文件数量:', validFiles.length); // console.log('【调试-handleContractAttachmentFilesSelected】有效文件:', validFiles.map(f => ({ name: f.name, size: f.size, type: f.type }))); - // setContractAttachmentFiles(validFiles); + setContractAttachmentFiles(validFiles); } else { console.error('【调试-handleContractAttachmentFilesSelected】没有有效的PDF文件或组件已卸载'); } @@ -703,17 +730,15 @@ export default function FilesUpload() { console.error('【调试-handleContractAttachmentFilesSelected】处理合同附件选择时发生错误:', error); } }; - */ - // 检查并准备上传 - 暂时未使用,保留以备将来扩展 - /* + // 检查并准备上传 const checkAndPrepareUpload = (mainFiles: File[], attachmentFiles: File[]) => { try { - // console.log('【调试-checkAndPrepareUpload】开始检查并准备上传文件', { - // mainFilesCount: mainFiles.length, - // attachmentFilesCount: attachmentFiles.length, - // fileType - // }); + console.log('【调试-checkAndPrepareUpload】开始检查并准备上传文件', { + mainFilesCount: mainFiles.length, + attachmentFilesCount: attachmentFiles.length, + fileType + }); // 检查组件是否已卸载 if (!isMountedRef.current) { @@ -749,11 +774,11 @@ export default function FilesUpload() { } // 记录主文件和附件文件信息 - // console.log('【调试-checkAndPrepareUpload】合同主文件:', mainFiles.map(f => ({ name: f.name, size: f.size, type: f.type }))); + console.log('【调试-checkAndPrepareUpload】合同主文件:', mainFiles.map(f => ({ name: f.name, size: f.size, type: f.type }))); if (attachmentFiles.length > 0) { - // console.log('【调试-checkAndPrepareUpload】合同附件文件:', attachmentFiles.map(f => ({ name: f.name, size: f.size, type: f.type }))); + console.log('【调试-checkAndPrepareUpload】合同附件文件:', attachmentFiles.map(f => ({ name: f.name, size: f.size, type: f.type }))); } else { - // console.log('【调试-checkAndPrepareUpload】无合同附件文件'); + console.log('【调试-checkAndPrepareUpload】无合同附件文件'); } } @@ -778,7 +803,7 @@ export default function FilesUpload() { setCurrentFiles(allFiles); // 将准备上传的操作移到这里,暂时不执行 - // console.log('【调试-checkAndPrepareUpload】准备上传', allFiles.length, '个文件'); + console.log('【调试-checkAndPrepareUpload】准备上传', allFiles.length, '个文件'); if (fileType) { try { @@ -829,12 +854,11 @@ export default function FilesUpload() { } } }; - */ // 开始上传文件 const startUpload = async (files: File[]) => { try { - // console.log('【调试-startUpload】开始上传过程,文件数量:', files.length); + console.log('【调试-startUpload】开始上传过程,文件数量:', files.length); // 检查组件是否已卸载 if (!isMountedRef.current) { @@ -878,14 +902,14 @@ export default function FilesUpload() { // console.log("【调试-startUpload】开始转换文件到二进制格式..."); // 模拟上传进度 - if (progressIntervalRef.current) { - clearInterval(progressIntervalRef.current); + if (uploadProgressIntervalRef.current) { + clearInterval(uploadProgressIntervalRef.current); } const startTime = Date.now(); let lastUploadedSize = 0; - progressIntervalRef.current = setInterval(() => { + uploadProgressIntervalRef.current = setInterval(() => { const currentTime = Date.now(); const timeElapsed = (currentTime - startTime) / 1000; // 转换为秒 const currentSpeed = (uploadedSize - lastUploadedSize) / timeElapsed; // 字节/秒 @@ -902,7 +926,12 @@ export default function FilesUpload() { // 上传所有文件 const uploadedFiles: UploadedFile[] = []; + let temp_n = 0; + let firstFileDocumentId: number | null = null; // 保存第一个文件的document_id + for (const file of files) { + temp_n++; + console.log('【调试-startUpload】上传文件:','第', temp_n, '个文件', file.name); try { // console.log(`【调试-startUpload】准备上传文件: ${file.name}, 大小: ${formatFileSize(file.size)}`); @@ -939,7 +968,9 @@ export default function FilesUpload() { priority, documentNumber || null, remark || null, - isTestDocument + isTestDocument, + temp_n > 1 ? firstFileDocumentId : null, // 第二个文件及以后使用第一个文件的document_id + false ); const timeoutPromise = new Promise((_, reject) => { @@ -964,6 +995,12 @@ export default function FilesUpload() { response = uploadResult; + // 保存第一个文件的document_id,用于后续附件上传 + if (temp_n === 1 && response.result?.id) { + firstFileDocumentId = response.result.id; + console.log('【调试-startUpload】保存第一个文件的document_id:', firstFileDocumentId); + } + // console.log(`【调试-startUpload】文件 ${file.name} 上传响应:`, response); } catch (error) { // 检查组件是否已卸载 @@ -1010,8 +1047,8 @@ export default function FilesUpload() { } // 清除进度定时器 - if (progressIntervalRef.current) { - clearInterval(progressIntervalRef.current); + if (uploadProgressIntervalRef.current) { + clearInterval(uploadProgressIntervalRef.current); } // 更新上传状态 @@ -1052,8 +1089,8 @@ export default function FilesUpload() { setProcessingSteps(errorSteps); // 清除进度定时器 - if (progressIntervalRef.current) { - clearInterval(progressIntervalRef.current); + if (uploadProgressIntervalRef.current) { + clearInterval(uploadProgressIntervalRef.current); } // 显示错误提示 @@ -1107,8 +1144,8 @@ export default function FilesUpload() { // console.log('【调试-startProcessing】开始处理文件,设置文件处理进度定时器'); // 清除之前的进度定时器(如果存在) - if (progressIntervalRef.current) { - clearInterval(progressIntervalRef.current); + if (processingStatusIntervalRef.current) { + clearInterval(processingStatusIntervalRef.current); } // 立即开始检查状态 @@ -1120,7 +1157,7 @@ export default function FilesUpload() { } // 设置文件处理进度定时器,每10秒检查一次状态 - progressIntervalRef.current = setInterval(() => { + processingStatusIntervalRef.current = setInterval(() => { // console.log('【调试-startProcessing】文件处理进度定时器触发,检查文件状态'); try { checkProcessingStatus(fileIds); @@ -1133,9 +1170,9 @@ export default function FilesUpload() { console.error('【调试-startProcessing】处理文件过程中发生错误:', error); // 清除进度定时器 - if (progressIntervalRef.current) { - clearInterval(progressIntervalRef.current); - progressIntervalRef.current = null; + if (processingStatusIntervalRef.current) { + clearInterval(processingStatusIntervalRef.current); + processingStatusIntervalRef.current = null; } // 更新步骤状态为错误 @@ -1205,9 +1242,9 @@ export default function FilesUpload() { // console.log('【调试-checkProcessingStatus】所有文件处理完成,更新步骤状态为完成'); // 清除文件处理进度定时器 - if (progressIntervalRef.current) { - clearInterval(progressIntervalRef.current); - progressIntervalRef.current = null; + if (processingStatusIntervalRef.current) { + clearInterval(processingStatusIntervalRef.current); + processingStatusIntervalRef.current = null; // console.log('【调试-checkProcessingStatus】文件处理完成,清除文件处理进度定时器'); } @@ -1316,10 +1353,15 @@ export default function FilesUpload() { // 重置上传状态 - 不清除队列状态检查定时器 const resetUpload = () => { - // 清除文件处理进度定时器 - if (progressIntervalRef.current) { - clearInterval(progressIntervalRef.current); - progressIntervalRef.current = null; + // 清除上传和处理相关的定时器 + if (uploadProgressIntervalRef.current) { + clearInterval(uploadProgressIntervalRef.current); + uploadProgressIntervalRef.current = null; + } + + if (processingStatusIntervalRef.current) { + clearInterval(processingStatusIntervalRef.current); + processingStatusIntervalRef.current = null; } // 重置状态 @@ -1331,8 +1373,8 @@ export default function FilesUpload() { setCompletedFiles([]); // 重置合同文件状态 - // setContractMainFiles([]); - // setContractAttachmentFiles([]); + setContractMainFiles([]); + setContractAttachmentFiles([]); // 重置步骤状态 setProcessingSteps([ @@ -1390,6 +1432,9 @@ export default function FilesUpload() { const handleViewFile = async (record: Document) => { try { // console.log('【调试-handleViewFile】开始处理查看文件,文件ID:', record.id); + // console.log('【调试-handleViewFile】开始处理查看文件,文件:', record); + + // 点击查看 // 检查audit_status是否为0,如果是则更新为2 if (record.audit_status === 0 || record.audit_status === null) { @@ -1448,8 +1493,8 @@ export default function FilesUpload() { width: "40%", render: (_: unknown, record: Document) => (
- - {record.name} + + {record.name || '未知文件'}
) }, @@ -1559,45 +1604,6 @@ export default function FilesUpload() { } ]; - // 添加路由阻止器 - // const shouldBlock = uploadStage === "uploading" || uploadStage === "processing"; - - // 使用useBlocker来阻止页面导航 - // const blocker = useBlocker( - // ({ nextLocation }) => { - // return shouldBlock && window.location.pathname !== nextLocation.pathname; - // } - // ); - - // // 处理阻止导航的逻辑 - // useEffect(() => { - // if (blocker.state === "blocked") { - // const confirmed = window.confirm( - // "文件正在上传或处理中,离开页面将中断操作。确定要离开吗?" - // ); - // if (confirmed) { - // blocker.proceed(); - // } else { - // blocker.reset(); - // } - // } - // }, [blocker]); - - // 添加页面刷新/关闭提示 - // useEffect(() => { - // const handleBeforeUnload = (e: BeforeUnloadEvent) => { - // if (shouldBlock) { - // e.preventDefault(); - // e.returnValue = "文件正在上传或处理中,离开页面将中断操作。确定要离开吗?"; - // return e.returnValue; - // } - // }; - - // window.addEventListener("beforeunload", handleBeforeUnload); - // return () => { - // window.removeEventListener("beforeunload", handleBeforeUnload); - // }; - // }, [shouldBlock]); return (
@@ -1688,7 +1694,7 @@ export default function FilesUpload() { {/* 自定义标题栏 */}

文件上传

- {/* {isContractType && uploadStage === "idle" && ( + {isContractType && uploadStage === "idle" && ( - )} */} + )}
{/* 初始上传区域 */} {uploadStage === "idle" && ( <> - {/* {!isContractType ? ( */} - {/* {true ? ( */} - {/* // 标准上传区域 - 非合同类型 */} + {!isContractType ? ( + // 标准上传区域 - 非合同类型 - {/* ) : ( */} - {/* 合同文件上传区域 - 双区域并排 */} - {/*
+ ) : ( + // 合同文件上传区域 - 双区域并排 +

合同主文件

- // {contractMainFiles.length > 0 && ( - //
- // - // 已选择主文件: {contractMainFiles[0].name} - //
- // )} - //
- //
- //

合同附件

- // - // {contractAttachmentFiles.length > 0 && ( - //
- // - // 已选择附件: {contractAttachmentFiles.map((file, index) => ( - // {file.name} - // ))} - //
- // )} - //
- //
- // )} + onFilesSelected={handleContractMainFilesSelected} + ref={contractMainFileRef} + multiple={false} + accept=".pdf" + tipText="请上传合同主文件,格式:PDF" + mainText="上传合同主文件" + buttonText="选择主文件" + icon="ri-file-text-line" + shouldPreventFileSelect={!fileType} + /> + {contractMainFiles.length > 0 && ( +
+ + 已选择主文件: {contractMainFiles[0].name} +
+ )} +
+
+

合同附件

+ + {contractAttachmentFiles.length > 0 && ( +
+ + 已选择附件: {contractAttachmentFiles.map((file, index) => ( + {file.name} + ))} +
+ )} +
+
+ )} {/* 测试文档标记 */}
@@ -1915,8 +1920,11 @@ export default function FilesUpload() { icon="ri-refresh-line" onClick={() => { // 清除所有定时器 - if (progressIntervalRef.current) { - clearInterval(progressIntervalRef.current); + if (uploadProgressIntervalRef.current) { + clearInterval(uploadProgressIntervalRef.current); + } + if (processingStatusIntervalRef.current) { + clearInterval(processingStatusIntervalRef.current); } // 重置状态 resetUpload(); diff --git a/app/routes/login.tsx b/app/routes/login.tsx index b1e4437..7b11910 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -1,8 +1,10 @@ -import { useState } from "react"; -import { useActionData, Form } from "@remix-run/react"; -import { type MetaFunction, type ActionFunctionArgs, redirect, type LoaderFunctionArgs } from "@remix-run/node"; +import { useEffect } from "react"; +import { useSearchParams } from "@remix-run/react"; +import { type MetaFunction, type LoaderFunctionArgs, redirect } from "@remix-run/node"; +import { OAuthClient } from "~/utils/oauth-client"; +import { OAUTH_CONFIG } from "~/config/api-config"; +import { getUserSession, getSession } from "~/root"; import styles from "~/styles/pages/login.css?url"; -import { getUserSession, getSession, type UserRole, sessionStorage } from "~/root"; export const links = () => [ { rel: "stylesheet", href: styles } @@ -15,71 +17,6 @@ export const meta: MetaFunction = () => { ]; }; -// 处理表单提交的action -export async function action({ request }: ActionFunctionArgs) { - const formData = await request.formData(); - const username = formData.get("username") as string; - const password = formData.get("password") as string; - const userRole = formData.get("userRole") as UserRole || 'common'; - - // console.log("userRole-----", userRole); - - // 简单的登录验证,实际应用中应该进行真正的身份验证 - if (!username || !password) { - return Response.json({ error: "用户名和密码不能为空" }); - } - - if(userRole === 'common') { - // console.log("username-----", username); - // console.log("password-----", password); - const validUsers = [ - { username: 'gdycuser', password: 'gdyc06111' }, - { username: 'gdycuser2', password: 'gdyc06112' }, - { username: 'gdycuser3', password: 'gdyc06113' } - ]; - const validUser = validUsers.find(user => user.username === username && user.password === password); - if (!validUser) { - return Response.json({ error: "普通用户用户名或密码错误" }); - } - } - - // console.log("login success", userRole); - - // 管理员登录 - if (userRole === 'developer') { - const validAdminUsers = [ - { username: 'admin', password: 'admin0611' }, - // { username: 'admin2', password: 'admin06112' }, - // { username: 'admin3', password: 'admin06113' } - ]; - const validAdminUser = validAdminUsers.find(user => user.username === username && user.password === password); - if (!validAdminUser) { - return Response.json({ error: "管理员用户名或密码错误" }); - } - } - - // 获取session中存储的重定向URL,如果没有则默认到/ - const session = await getSession(request); - // 查看session中存储的redirectTo值 - const redirectTo = session.get("redirectTo") || "/"; - // console.log("登录后重定向到:", redirectTo); - - // 创建会话cookie - const newSession = await sessionStorage.getSession(); - newSession.set("isAuthenticated", true); - newSession.set("userRole", userRole); - const cookie = await sessionStorage.commitSession(newSession); - - // console.log("设置cookie:", !!cookie); - - // 使用新方法进行重定向 - return redirect(redirectTo, { - headers: { - "Set-Cookie": cookie - } - }); -} - // 加载器,获取当前会话状态 export async function loader({ request }: LoaderFunctionArgs) { const { isAuthenticated } = await getUserSession(request); @@ -88,96 +25,112 @@ export async function loader({ request }: LoaderFunctionArgs) { if (isAuthenticated) { return redirect("/"); } + + // 获取重定向URL并保存到session + const url = new URL(request.url); + const redirectTo = url.searchParams.get("redirect") || "/"; - return Response.json({ isAuthenticated }); + const session = await getSession(request); + session.set("redirectTo", redirectTo); + + return Response.json({ + isAuthenticated: false, + redirectTo + }); } export default function Login() { - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [userRole, setUserRole] = useState("common"); - const [showPassword, setShowPassword] = useState(false); - const actionData = useActionData(); + const [searchParams] = useSearchParams(); + const error = searchParams.get("error"); + + // 获取错误消息的友好描述 + const getErrorMessage = (error: string | null) => { + if (!error) return null; + + switch (error) { + case "missing_code": + return "登录过程中缺少授权码,请重新登录"; + case "invalid_state": + return "登录状态验证失败,请重新登录"; + case "token_error": + return "获取访问令牌失败,请重新登录"; + case "userinfo_error": + return "获取用户信息失败,请重新登录"; + case "callback_error": + return "登录回调处理失败,请重新登录"; + default: + return decodeURIComponent(error); + } + }; + + // 处理OAuth2.0登录 + const handleOAuthLogin = () => { + try { + // 创建OAuth客户端 + const oauthClient = new OAuthClient(OAUTH_CONFIG); + + // 生成状态值 + const state = oauthClient.generateState(); + + // 将状态值保存到localStorage(用于后续验证) + localStorage.setItem("oauth_state", state); + + // 获取授权URL + const authorizeUrl = oauthClient.getAuthorizeUrl(state); + + // 重定向到IDaaS登录页面 + window.location.href = authorizeUrl; + } catch (error) { + console.error("启动OAuth2.0登录失败:", error); + alert("登录系统初始化失败,请联系系统管理员"); + } + }; + + useEffect(() => { + // 检查OAuth配置是否完整 + if (!OAUTH_CONFIG.serverUrl || !OAUTH_CONFIG.clientId || !OAUTH_CONFIG.clientSecret) { + console.error("OAuth2.0配置不完整:", OAUTH_CONFIG); + } + }, []); return (
- {/* 中国烟草 */}

中国烟草AI合同及卷宗审核系统

-

用户登录

-
- {actionData?.error && ( -
-
-
{actionData.error}
-
- )} - -
- - setUsername(e.target.value)} - className="form-input" - placeholder="请输入用户名" - /> +

统一身份认证登录

+ + {error && ( +
+
+
{getErrorMessage(error)}
- -
- -
- setPassword(e.target.value)} - className="form-input password-input" - placeholder="请输入密码" - /> - -
-
- -
- - + )} + +
+
+

请点击下方按钮进行统一身份认证登录

- + +
+

+ + 系统将跳转到统一身份认证平台进行登录 +

+
+
diff --git a/app/routes/logout.tsx b/app/routes/logout.tsx new file mode 100644 index 0000000..a1972ec --- /dev/null +++ b/app/routes/logout.tsx @@ -0,0 +1,51 @@ +import { type LoaderFunctionArgs, redirect } from "@remix-run/node"; +import { OAuthClient } from "~/utils/oauth-client"; +import { OAUTH_CONFIG } from "~/config/api-config"; +import { sessionStorage } from "~/root"; + +export async function loader({ request }: LoaderFunctionArgs) { + const session = await sessionStorage.getSession(request.headers.get("Cookie")); + + // 获取访问令牌 + const accessToken = session.get("accessToken"); + + if (accessToken) { + try { + // 创建OAuth客户端 + const oauthClient = new OAuthClient(OAUTH_CONFIG); + + // 构建登出后重定向URL + const url = new URL(request.url); + const redirectUrl = url.searchParams.get("redirect") || `${url.protocol}//${url.host}/login`; + + // 调用IDaaS单点登出 + const logoutSuccess = await oauthClient.logout(accessToken, redirectUrl); + + if (!logoutSuccess) { + console.warn("IDaaS单点登出失败,但仍清除本地会话"); + } + } catch (error) { + console.error("单点登出过程中出错:", error); + } + } + + // 无论IDaaS登出是否成功,都清除本地会话 + const cookie = await sessionStorage.destroySession(session); + + return redirect("/login", { + headers: { + "Set-Cookie": cookie + } + }); +} + +export default function Logout() { + return ( +
+
+
+

正在退出登录...

+
+
+ ); +} \ No newline at end of file diff --git a/app/routes/reviews.tsx b/app/routes/reviews.tsx index 38c8b86..cfcbe5c 100644 --- a/app/routes/reviews.tsx +++ b/app/routes/reviews.tsx @@ -39,7 +39,8 @@ import { FilePreview, ReviewPointsList, AIAnalysis, - FileDetails + FileDetails, + Comparison } from "~/components/reviews"; // 从ReviewPointsList组件中导入ReviewPoint类型 @@ -200,7 +201,8 @@ export async function loader({ request }: LoaderFunctionArgs) { document: reviewData.document, reviewPoints: reviewData.data, reviewInfo: reviewData.reviewInfo, - statistics: reviewData.stats + statistics: reviewData.stats, + comparison_document: reviewData.comparison_document }); } else { console.error("返回的评查数据格式不正确",JSON.stringify(reviewData,null,2)); @@ -215,12 +217,13 @@ export async function loader({ request }: LoaderFunctionArgs) { export default function ReviewDetails() { const navigate = useNavigate(); const loaderData = useLoaderData(); - const { document, reviewPoints, statistics, reviewInfo } = loaderData; + const { document, reviewPoints, statistics, reviewInfo, comparison_document } = loaderData; const [isLoading, setIsLoading] = useState(false); // 已经通过loader加载了数据,不需要再显示加载状态 const [activeTab, setActiveTab] = useState('preview'); // 'preview', 'analysis', 'fileinfo' const [reviewData, setReviewData] = useState(null); const [activeReviewPointResultId, setActiveReviewPointResultId] = useState(null); const [targetPage, setTargetPage] = useState(undefined); + const [templateTargetPage, setTemplateTargetPage] = useState(undefined); // loader 数据加载出错 useEffect(()=>{ @@ -584,6 +587,7 @@ export default function ReviewDetails() { activeTab={activeTab} onTabChange={handleTabChange} fileInfo={{ + id: document?.id, previousRoute: loaderData.previousRoute, path: document?.path, auditStatus: document?.auditStatus, @@ -621,7 +625,7 @@ export default function ReviewDetails() { {activeTab === 'filecompare' && (
{/* 左侧:原文件预览 */} -
+
{/* 中间:附件文件预览 */} -
+
- {/* 右侧:评查结果 */} + {/* 右侧:结构比较结果 */}
- { + // 同时处理主文件和模板文件的页码跳转 + if (sourcePage > 0) { + // 如果目标页码与当前页码相同,先重置再设置以强制触发更新 + if (sourcePage === targetPage) { + setTargetPage(undefined); + setTimeout(() => setTargetPage(sourcePage), 0); + } else { + setTargetPage(sourcePage); + } + console.log(`跳转到主文件第${sourcePage}页`); + } + if (templatePage > 0) { + // 如果目标页码与当前页码相同,先重置再设置以强制触发更新 + if (templatePage === templateTargetPage) { + setTemplateTargetPage(undefined); + setTimeout(() => setTemplateTargetPage(templatePage), 0); + } else { + setTemplateTargetPage(templatePage); + } + console.log(`跳转到模板文件第${templatePage}页`); + } + }} />
diff --git a/app/routes/rules-files.tsx b/app/routes/rules-files.tsx index 021ead0..ed02863 100644 --- a/app/routes/rules-files.tsx +++ b/app/routes/rules-files.tsx @@ -123,7 +123,7 @@ export default function RulesFiles() { // 根据 reviewType 添加类型过滤 if (reviewType === 'contract') { - searchParams.fileType = '1'; + searchParams.fileType = 'contract'; } else if (reviewType === 'record') { // 在 API 层处理 type_id 为 2 或 3 的过滤 searchParams.fileType = 'record'; diff --git a/app/styles/pages/login.css b/app/styles/pages/login.css index 4433aa1..9be595b 100644 --- a/app/styles/pages/login.css +++ b/app/styles/pages/login.css @@ -50,121 +50,161 @@ margin-bottom: 2rem; } -.login-form { +/* OAuth2.0 登录样式 */ +.oauth-login-section { display: flex; flex-direction: column; + align-items: center; gap: 1.5rem; } -/* 优化的错误提示样式 */ +.login-description { + text-align: center; + color: #666; + margin-bottom: 1rem; +} + +.login-description p { + font-size: 0.95rem; + line-height: 1.5; +} + +.oauth-login-button { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + width: 100%; + padding: 0.875rem 1.5rem; + background: linear-gradient(135deg, #015c42 0%, #01704e 100%); + color: white; + border: none; + border-radius: 6px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.oauth-login-button:hover { + background: linear-gradient(135deg, #01704e 0%, #015c42 100%); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(1, 92, 66, 0.3); +} + +.oauth-login-button:active { + transform: translateY(0); +} + +.oauth-login-button i { + font-size: 1.25rem; + opacity: 0.9; +} + +.login-tips { + text-align: center; + color: #888; + font-size: 0.85rem; + margin-top: 1rem; +} + +.login-tips p { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin: 0; +} + +.login-tips i { + font-size: 0.95rem; + color: #999; +} + +/* 错误消息样式 */ .error-message-container { display: flex; align-items: center; + gap: 0.5rem; padding: 0.75rem 1rem; background-color: #fef2f2; - border: 1px solid #fee2e2; - border-radius: 6px; - animation: fadeIn 0.3s ease-in-out; + border-left: 4px solid #ef4444; + border-radius: 4px; + margin-bottom: 1.5rem; + animation: fadeIn 0.3s ease; } .error-icon { color: #ef4444; - font-size: 1.25rem; - margin-right: 0.75rem; - display: flex; - align-items: center; + font-size: 1.1rem; + flex-shrink: 0; } .error-text { - color: #b91c1c; - font-size: 0.875rem; - font-weight: 500; + color: #dc2626; + font-size: 0.9rem; + line-height: 1.4; } +.login-footer { + text-align: center; + color: #888; + font-size: 0.85rem; +} + +.login-footer p { + margin: 0; +} + +/* 动画效果 */ @keyframes fadeIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } -.form-group { - display: flex; - flex-direction: column; - gap: 0.5rem; +/* 响应式设计 */ +@media (max-width: 640px) { + .login-container { + margin: 1rem; + padding: 1.5rem; + } + + .login-title { + font-size: 1.3rem; + } + + .login-subtitle { + font-size: 1.1rem; + } + + .oauth-login-button { + padding: 0.75rem 1.25rem; + font-size: 0.95rem; + } } -.form-group label { - font-size: 0.875rem; - font-weight: 500; - color: #555; -} - -.form-input { - padding: 0.75rem 1rem; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 1rem; - transition: border-color 0.2s; -} - -.form-input:focus { - border-color: #2cad7d; - outline: none; - box-shadow: 0 0 0 2px rgba(44, 173, 125, 0.2); -} - -/* 密码输入框容器样式 */ -.password-input-container { - position: relative; - display: flex; - align-items: center; -} - -.password-input { - padding-right: 3rem; /* 为眼睛图标留出空间 */ -} - -.password-toggle-btn { - position: absolute; - right: 0.75rem; - top: 50%; - transform: translateY(-50%); - background: none; - border: none; - cursor: pointer; - padding: 0.25rem; - color: #666; - font-size: 1.25rem; - display: flex; - align-items: center; - justify-content: center; - border-radius: 4px; - transition: color 0.2s, background-color 0.2s; -} - -.password-toggle-btn:hover { - color: #2cad7d; -} - - -.login-button { - margin-top: 1rem; - padding: 0.75rem 1.5rem; - background-color: #2cad7d; - color: white; - border: none; - border-radius: 4px; - font-size: 1rem; - font-weight: 500; - cursor: pointer; - transition: background-color 0.2s; -} - -.login-button:hover { - background-color: #1e9668; -} - -.login-footer { - text-align: center; - font-size: 0.875rem; - color: #777; +/* 暗色主题支持 */ +@media (prefers-color-scheme: dark) { + .login-container { + background-color: #1f2937; + color: #f9fafb; + } + + .login-subtitle { + color: #e5e7eb; + } + + .login-description { + color: #9ca3af; + } + + .login-tips { + color: #6b7280; + } + + .login-footer { + color: #6b7280; + } } \ No newline at end of file diff --git a/app/utils/oauth-client.ts b/app/utils/oauth-client.ts new file mode 100644 index 0000000..9cf59be --- /dev/null +++ b/app/utils/oauth-client.ts @@ -0,0 +1,211 @@ +/** + * OAuth2.0客户端类 + * 用于处理IDaaS OAuth2.0认证流程 + */ + +interface OAuthConfig { + serverUrl: string; + clientId: string; + clientSecret: string; + redirectUri: string; + appId: string; +} + +interface TokenResponse { + access_token: string; + token_type: string; + refresh_token: string; + expires_in: number; + scope: string; + jti: string; +} + +interface UserInfoResponse { + success: boolean; + code: string; + message: string | null; + requestId: string; + data: { + sub: string; + ou_id: string; + nickname: string; + phone_number: string; + ou_name: string; + email: string; + username: string; + }; +} + +export class OAuthClient { + private config: OAuthConfig; + + constructor(config: OAuthConfig) { + this.config = { + ...config, + serverUrl: config.serverUrl.replace(/\/$/, '') // 移除末尾斜杠 + }; + } + + /** + * 生成授权URL + * @param state 状态值,建议包含随机字符串和_idp后缀 + * @returns 授权URL + */ + getAuthorizeUrl(state: string): string { + const params = new URLSearchParams({ + response_type: 'code', + scope: 'read', + client_id: this.config.clientId, + redirect_uri: this.config.redirectUri, + state: state + }); + + return `${this.config.serverUrl}/oauth/authorize?${params.toString()}`; + } + + /** + * 获取访问令牌 + * @param code 授权码 + * @returns 访问令牌响应 + */ + async getAccessToken(code: string): Promise { + const url = `${this.config.serverUrl}/oauth/token`; + const data = new URLSearchParams({ + grant_type: 'authorization_code', + code: code, + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + redirect_uri: this.config.redirectUri + }); + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: data + }); + + if (!response.ok) { + const errorData = await response.json(); + console.error('获取访问令牌失败:', errorData); + return null; + } + + return await response.json() as TokenResponse; + } catch (error) { + console.error('获取访问令牌网络错误:', error); + return null; + } + } + + /** + * 获取用户信息 + * @param accessToken 访问令牌 + * @returns 用户信息响应 + */ + async getUserInfo(accessToken: string): Promise { + const url = `${this.config.serverUrl}/api/bff/v1.2/oauth2/userinfo`; + + try { + const response = await fetch(url, { + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }); + + if (!response.ok) { + console.error('获取用户信息失败:', response.status, response.statusText); + return null; + } + + return await response.json() as UserInfoResponse; + } catch (error) { + console.error('获取用户信息网络错误:', error); + return null; + } + } + + /** + * 单点登出 + * @param accessToken 访问令牌 + * @param redirectUrl 登出后重定向URL + * @returns 登出是否成功 + */ + async logout(accessToken: string, redirectUrl: string): Promise { + const url = `${this.config.serverUrl}/public/sp/slo/${this.config.appId}`; + const data = new URLSearchParams({ + access_token: accessToken, + redirect_url: redirectUrl + }); + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: data + }); + + return response.ok; + } catch (error) { + console.error('登出失败:', error); + return false; + } + } + + /** + * 生成随机状态值 + * @returns 状态值字符串 + */ + generateState(): string { + const randomStr = Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15); + return `${randomStr}_idp`; + } +} + +/** + * OAuth2.0工具函数 + */ +export const oauthUtils = { + /** + * 从URL中提取查询参数 + * @param url URL字符串 + * @returns 查询参数对象 + */ + getQueryParams(url: string): Record { + const params: Record = {}; + const urlObj = new URL(url); + + for (const [key, value] of urlObj.searchParams) { + params[key] = value; + } + + return params; + }, + + /** + * 验证状态值 + * @param state 返回的状态值 + * @param expectedState 期望的状态值 + * @returns 是否匹配 + */ + validateState(state: string, expectedState: string): boolean { + return state === expectedState; + }, + + /** + * 检查访问令牌是否过期 + * @param tokenInfo 令牌信息 + * @param issuedAt 令牌颁发时间戳 + * @returns 是否过期 + */ + isTokenExpired(tokenInfo: TokenResponse, issuedAt: number): boolean { + const now = Date.now(); + const expiresAt = issuedAt + (tokenInfo.expires_in * 1000); + return now >= expiresAt; + } +}; \ No newline at end of file diff --git a/login/OAuth2.0认证协议集成指南.md b/login/OAuth2.0认证协议集成指南.md new file mode 100644 index 0000000..a77f8d5 --- /dev/null +++ b/login/OAuth2.0认证协议集成指南.md @@ -0,0 +1,576 @@ +# OAuth2.0 认证协议集成开发指南 + +## 📋 目录 +- [1. 术语定义](#1-术语定义) +- [2. 业务场景说明](#2-业务场景说明) +- [3. 集成流程概览](#3-集成流程概览) +- [4. 详细集成步骤](#4-详细集成步骤) +- [5. API接口详解](#5-api接口详解) +- [6. 错误处理](#6-错误处理) +- [7. 注意事项](#7-注意事项) +- [8. 示例代码](#8-示例代码) + +## 1. 术语定义 + +### 🔍 核心概念 +| 术语 | 全称 | 说明 | +|------|------|------| +| **SP** | Service Provider | 业务系统,如OA系统、订单系统 | +| **IDaaS** | Identity as a Service | 提供统一身份服务的认证系统平台,即IDP | + +## 2. 业务场景说明 + +### 📱 应用场景 +业务系统作为SP,需要集成IDaaS的单点登录和单点登出功能。 + +### 🎯 核心目标 +- **单点登录**:用户通过IDaaS认证后,可以访问所有授权的应用 +- **单点登出**:用户在任意应用登出后,所有关联应用都会登出 +- **用户信息同步**:获取用户在IDaaS平台的身份信息 + +### 💡 实现方式 +1. **门户集成**:用户通过IDaaS门户选择应用进行登录 +2. **独立登录**:业务系统提供独立登录页面,调用IDaaS接口 +3. **API直接调用**:通过AK/SK方式直接调用IDaaS登录接口 + +## 3. 集成流程概览 + +```mermaid +sequenceDiagram + participant User as 用户 + participant SP as 业务系统(SP) + participant IDaaS as IDaaS平台 + + Note over User,IDaaS: 1. 配置OAuth2应用 + SP->>IDaaS: 在IDaaS平台创建OAuth2应用 + IDaaS-->>SP: 返回client_id和client_secret + + Note over User,IDaaS: 2. 用户登录流程 + User->>SP: 访问业务系统 + SP->>User: 重定向到IDaaS登录页 + User->>IDaaS: 在IDaaS完成登录 + IDaaS->>SP: 返回authorization code + SP->>IDaaS: 使用code获取access_token + IDaaS-->>SP: 返回access_token + SP->>IDaaS: 使用access_token获取用户信息 + IDaaS-->>SP: 返回用户详细信息 + SP-->>User: 完成登录,访问业务系统 + + Note over User,IDaaS: 3. 用户登出流程 + User->>SP: 请求登出 + SP->>IDaaS: 调用IDaaS登出接口 + IDaaS-->>SP: 登出成功 + SP-->>User: 重定向到登录页 +``` + +## 4. 详细集成步骤 + +### 4.1 配置OAuth2第三方应用 + +#### 📝 配置步骤 +1. 使用管理员登录IDaaS平台 +2. 创建新应用,选择标准协议 → OAuth2模式 +3. 配置应用基本信息 + +#### ⚙️ 关键配置项 +| 配置项 | 说明 | 示例 | +|--------|------|------| +| **Redirect URI** | 授权码模式下,接收IDaaS返回code的回调地址 | `http://oa.com/callback` | +| **Grant Type** | 授权类型,固定选择 | `authorization_code` | +| **Client ID** | 应用唯一标识 | `1d0f8349ad981fe9a306e39d86a3a124uHW6fd0sC7U` | +| **Client Secret** | 应用密钥 | `vdDtBPNiHRCz8S267fURHYnr2bMlgL7ahqrpgrDscG` | + +### 4.2 对接IDaaS登录 + +#### 🚀 登录方式选择 + +##### 方式一:使用IDaaS统一登录页 +**适用场景**:不需要自定义登录页面样式的应用 + +**流程说明**: +1. 构建授权URL,引导用户跳转到IDaaS登录页 +2. 用户完成登录后,IDaaS回调业务系统 +3. 业务系统获取code,换取access_token +4. 使用access_token获取用户信息 + +## 5. API接口详解 + +### 5.1 获取授权码(Authorization Code) + +#### 📌 接口描述 +引导用户到IDaaS登录页面,获取授权码 + +http://10.79.112.85/oauth/authorize?response_type=code&scope=read&client_id=54d2a619fe5c81ae1250434c441fccccqMtKwh7H4fO&redirect_uri=http%3a%2f%2f10.79.97.17%2f&state=10ff0be64971c07f893afc332877f68arS8FH2iyZni + +#### 🔗 请求URL格式 +``` +http(s)://{IDaaS_server}/oauth/authorize?response_type=code&scope=read&client_id={client_id}&redirect_uri={redirect_uri}&state={state} +``` + +#### 📋 请求参数 +| 参数名 | 类型 | 必填 | 示例值 | 说明 | +|--------|------|------|--------|------| +| `response_type` | string | ✅ | `code` | 响应类型,固定为code | +| `scope` | string | ✅ | `read` | 授权范围,固定为read | +| `client_id` | string | ✅ | `1d0f8349ad981fe9a306e39d86a3a124uHW6fd0sC7U` | OAuth2应用的Client ID | +| `redirect_uri` | string | ✅ | `http%3A%2F%2Foa.com%2Fcallback` | 回调地址(需URL编码) | +| `state` | string | ✅ | `10ff0be64971c07f893afc332877f68arS8FH2iyZni_idp` | 状态值,建议包含`_idp`后缀 | + +#### 💡 完整示例 +``` +http://idaas.example.com/oauth/authorize?response_type=code&scope=read&client_id=1d0f8349ad981fe9a306e39d86a3a124uHW6fd0sC7U&redirect_uri=http%3A%2F%2Foa.com%2Fcallback&state=10ff0be64971c07f893afc332877f68arS8FH2iyZni_idp +``` + +### 5.2 获取访问令牌(Access Token) + +#### 📌 接口描述 +使用授权码换取访问令牌 + +#### 🔗 请求信息 +- **URL**: `http(s)://{IDaaS_server}/oauth/token` +- **方法**: `POST` +- **Content-Type**: `application/x-www-form-urlencoded` + +#### 📋 请求参数 +| 参数名 | 类型 | 必填 | 示例值 | 说明 | +|--------|------|------|--------|------| +| `grant_type` | string | ✅ | `authorization_code` | 授权类型,固定值 | +| `code` | string | ✅ | `WgWQe6` | 从回调中获取的授权码 | +| `client_id` | string | ✅ | `1d0f8349ad981fe9a306e39d86a3a124uHW6fd0sC7U` | OAuth2应用的Client ID | +| `client_secret` | string | ✅ | `vdDtBPNiHRCz8S267fURHYnr2bMlgL7ahqrpgrDscG` | OAuth2应用的Client Secret | +| `redirect_uri` | string | ✅ | `http%3A%2F%2Foa.com%2Fcallback` | 回调地址(需URL编码) | + +#### 📤 cURL示例 +```bash +curl -X POST 'http://idaas.example.com/oauth/token' \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -d 'grant_type=authorization_code&code=dIKvfA&client_id=1d0f8349ad981fe9a306e39d86a3a124uHW6fd0sC7U&client_secret=vdDtBPNiHRCz8S267fURHYnr2bMlgL7ahqrpgrDscG&redirect_uri=http%3A%2F%2Foa.com%2Fcallback' +``` + +#### ✅ 成功响应 +```json +{ + "access_token": "eyJhbGciO...", + "token_type": "bearer", + "refresh_token": "eyJhbGciOiJIUzI1...", + "expires_in": 7199, + "scope": "read", + "jti": "17147278-7f3e-45f2-be6f-8105c4334a30" +} +``` + +### 5.3 获取用户信息 + +#### 📌 接口描述 +使用访问令牌获取用户详细信息 + +#### 🔗 请求信息 +- **URL**: `https://{IDaaS_server}/api/bff/v1.2/oauth2/userinfo` +- **方法**: `GET` +- **认证**: Bearer Token + +#### 📋 请求参数 +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| `access_token` | string | ✅ | 访问令牌(可作为URL参数或Header) | + +#### 📤 请求示例 +```bash +# 方式1: URL参数 +GET https://idaas.example.com/api/bff/v1.2/oauth2/userinfo?access_token=eyJhbGc1NiIs... + +# 方式2: Authorization Header +curl -H "Authorization: Bearer eyJhbGc1NiIs..." \ + https://idaas.example.com/api/bff/v1.2/oauth2/userinfo +``` + +#### ✅ 成功响应 +```json +{ + "success": true, + "code": "200", + "message": null, + "requestId": "149DA248-8F49-4820-B87A-5EA36D932354", + "data": { + "sub": "823071756087671783", + "ou_id": "2079225187122667069", + "nickname": "测试用户", + "phone_number": "11136618971", + "ou_name": "测试组织IDAAS", + "email": "test@test.com", + "username": "test" + } +} +``` + +#### 📊 响应字段说明 +| 字段名 | 类型 | 说明 | +|--------|------|------| +| `sub` | string | 用户唯一标识 | +| `ou_id` | string | 组织ID | +| `nickname` | string | 用户昵称 | +| `phone_number` | string | 手机号码 | +| `ou_name` | string | 组织名称 | +| `email` | string | 邮箱地址 | +| `username` | string | 用户名 | + +### 5.4 单点登出(SLO) + +#### 📌 接口描述 +实现全局统一登出功能 + +#### 🔗 请求信息 +- **URL**: `http(s)://{IDaaS_server}/public/sp/slo/{appId}` +- **方法**: `GET` 或 `POST`(推荐POST) + +#### 📋 请求参数 +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| `appId` | string | ✅ | 应用ID(路径参数) | +| `redirect_url` | string | ❌ | 登出成功后的重定向URL(需URL编码) | +| `access_token` | string | ❌ | 用户的访问令牌 | + +#### 📤 请求示例 +```bash +# GET请求 +http://idaas.example.com/public/sp/slo/idaasoauth2?access_token=xxxxxxx&redirect_url=https%3A%2F%2Fwww.example.com%2F + +# POST请求(推荐) +curl -X POST 'http://idaas.example.com/public/sp/slo/idaasoauth2' \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -d 'access_token=xxxxxxx&redirect_url=https%3A%2F%2Fwww.example.com%2F' +``` + +## 6. 错误处理 + +### ❌ 常见错误响应 + +#### Token相关错误 +```json +// 客户端认证失败 +{ + "error": "invalid_client", + "error_description": "Bad client credentials" +} + +// 授权码无效 +{ + "error": "invalid_grant", + "error_description": "Invalid authorization code: dIKvfA" +} + +// 授权码过期 +{ + "error": "invalid_grant", + "error_description": "authorization code expired: WgWQe6" +} +``` + +#### HTTP状态码说明 +| 状态码 | 错误类型 | 说明 | +|--------|----------|------| +| `401` | Unauthorized | 未授权的访问 | +| `403` | Forbidden | 权限不足 | +| `404` | ResourceNotFound | 访问的资源不存在 | +| `415` | UnsupportedMediaType | 不支持的媒体类型 | +| `500` | InternalError | 服务器内部错误 | + +## 7. 注意事项 + +### ⚠️ 重要提醒 + +#### 多端访问处理 +当企业内网同时有PC端Web应用和移动端H5应用时,需要根据`remote-user`请求头字段进行判断: + +- **`remote-user`为NULL**: 从企业内网登录 → 使用原始地址 +- **`remote-user`不为NULL**: 从企业外网登录 → 使用代理地址 + +#### URL地址转换规则 +``` +原始地址: http://xx.YY.zzz.AA +代理地址: https://xx-YY-zzz-AA-kkkkkkkkkkkk.ztna-dingtalk.com +``` + +#### 移动端适配 +可以通过UserAgent等信息进行设备类型判断,实现不同终端的差异化跳转。 + +### 🔐 安全建议 + +1. **HTTPS传输**: 生产环境务必使用HTTPS协议 +2. **State参数**: 使用随机且不可预测的state值防止CSRF攻击 +3. **Token保护**: 妥善保存client_secret和access_token +4. **回调验证**: 验证回调请求的来源和参数完整性 +5. **Token过期**: 及时处理token过期和刷新逻辑 + +## 8. 示例代码 + +### 🐍 Python集成示例 + +```python +import requests +import urllib.parse +from typing import Dict, Optional + +class IDaaSClient: + def __init__(self, server_url: str, client_id: str, client_secret: str, redirect_uri: str): + self.server_url = server_url.rstrip('/') + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + + def get_authorize_url(self, state: str) -> str: + """生成授权URL""" + params = { + 'response_type': 'code', + 'scope': 'read', + 'client_id': self.client_id, + 'redirect_uri': self.redirect_uri, + 'state': state + } + + query_string = urllib.parse.urlencode(params) + return f"{self.server_url}/oauth/authorize?{query_string}" + + def get_access_token(self, code: str) -> Optional[Dict]: + """使用授权码获取访问令牌""" + url = f"{self.server_url}/oauth/token" + data = { + 'grant_type': 'authorization_code', + 'code': code, + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'redirect_uri': self.redirect_uri + } + + try: + response = requests.post(url, data=data) + response.raise_for_status() + return response.json() + except requests.RequestException as e: + print(f"获取token失败: {e}") + return None + + def get_user_info(self, access_token: str) -> Optional[Dict]: + """获取用户信息""" + url = f"{self.server_url}/api/bff/v1.2/oauth2/userinfo" + headers = {'Authorization': f'Bearer {access_token}'} + + try: + response = requests.get(url, headers=headers) + response.raise_for_status() + return response.json() + except requests.RequestException as e: + print(f"获取用户信息失败: {e}") + return None + + def logout(self, app_id: str, access_token: str, redirect_url: str) -> bool: + """单点登出""" + url = f"{self.server_url}/public/sp/slo/{app_id}" + data = { + 'access_token': access_token, + 'redirect_url': redirect_url + } + + try: + response = requests.post(url, data=data) + return response.status_code == 200 + except requests.RequestException as e: + print(f"登出失败: {e}") + return False + +# 使用示例 +if __name__ == "__main__": + # 初始化客户端 + client = IDaaSClient( + server_url="http://idaas.example.com", + client_id="your_client_id", + client_secret="your_client_secret", + redirect_uri="http://your-app.com/callback" + ) + + # 1. 生成登录URL(重定向用户到此URL) + state = "random_state_value_with_idp" + login_url = client.get_authorize_url(state) + print(f"登录URL: {login_url}") + + # 2. 处理回调(从query参数获取code) + code = "received_code_from_callback" + token_response = client.get_access_token(code) + + if token_response: + access_token = token_response['access_token'] + print(f"Access Token: {access_token}") + + # 3. 获取用户信息 + user_info = client.get_user_info(access_token) + if user_info and user_info['success']: + user_data = user_info['data'] + print(f"用户信息: {user_data}") + + # 4. 登出 + logout_success = client.logout("your_app_id", access_token, "http://your-app.com/login") + print(f"登出结果: {'成功' if logout_success else '失败'}") +``` + +### 🌐 JavaScript集成示例 + +```javascript +class IDaaSClient { + constructor(serverUrl, clientId, clientSecret, redirectUri) { + this.serverUrl = serverUrl.replace(/\/$/, ''); + this.clientId = clientId; + this.clientSecret = clientSecret; + this.redirectUri = redirectUri; + } + + // 生成授权URL + getAuthorizeUrl(state) { + const params = new URLSearchParams({ + response_type: 'code', + scope: 'read', + client_id: this.clientId, + redirect_uri: this.redirectUri, + state: state + }); + + return `${this.serverUrl}/oauth/authorize?${params.toString()}`; + } + + // 获取访问令牌 + async getAccessToken(code) { + const url = `${this.serverUrl}/oauth/token`; + const data = new URLSearchParams({ + grant_type: 'authorization_code', + code: code, + client_id: this.clientId, + client_secret: this.clientSecret, + redirect_uri: this.redirectUri + }); + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: data + }); + + return await response.json(); + } catch (error) { + console.error('获取token失败:', error); + return null; + } + } + + // 获取用户信息 + async getUserInfo(accessToken) { + const url = `${this.serverUrl}/api/bff/v1.2/oauth2/userinfo`; + + try { + const response = await fetch(url, { + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }); + + return await response.json(); + } catch (error) { + console.error('获取用户信息失败:', error); + return null; + } + } + + // 单点登出 + async logout(appId, accessToken, redirectUrl) { + const url = `${this.serverUrl}/public/sp/slo/${appId}`; + const data = new URLSearchParams({ + access_token: accessToken, + redirect_url: redirectUrl + }); + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: data + }); + + return response.ok; + } catch (error) { + console.error('登出失败:', error); + return false; + } + } +} + +// 使用示例 +const client = new IDaaSClient( + 'http://idaas.example.com', + 'your_client_id', + 'your_client_secret', + 'http://your-app.com/callback' +); + +// 处理登录流程 +async function handleLogin() { + // 1. 重定向到IDaaS登录页 + const state = 'random_state_value_with_idp'; + const loginUrl = client.getAuthorizeUrl(state); + window.location.href = loginUrl; +} + +// 处理回调 +async function handleCallback() { + const urlParams = new URLSearchParams(window.location.search); + const code = urlParams.get('code'); + const state = urlParams.get('state'); + + if (code) { + // 2. 获取访问令牌 + const tokenResponse = await client.getAccessToken(code); + + if (tokenResponse && tokenResponse.access_token) { + const accessToken = tokenResponse.access_token; + + // 3. 获取用户信息 + const userInfo = await client.getUserInfo(accessToken); + + if (userInfo && userInfo.success) { + console.log('用户信息:', userInfo.data); + // 保存用户信息到localStorage或状态管理 + localStorage.setItem('access_token', accessToken); + localStorage.setItem('user_info', JSON.stringify(userInfo.data)); + } + } + } +} + +// 处理登出 +async function handleLogout() { + const accessToken = localStorage.getItem('access_token'); + + if (accessToken) { + const success = await client.logout('your_app_id', accessToken, window.location.origin + '/login'); + + if (success) { + localStorage.removeItem('access_token'); + localStorage.removeItem('user_info'); + window.location.href = '/login'; + } + } +} +``` + +--- + +## 📞 技术支持 + +如需更多技术支持,请参考: +- IDaaS平台管理后台 +- 相关API文档 +- 集成Demo项目 + +**注意**: 本文档基于OAuth2.0标准协议,具体实现可能因IDaaS平台版本而有所差异,请以实际平台配置为准。 \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index f9968cf..32c0ca5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,47 +1,49 @@ -import { vitePlugin as remix } from "@remix-run/dev"; -import { defineConfig } from "vite"; -import tsconfigPaths from "vite-tsconfig-paths"; - -declare module "@remix-run/node" { - interface Future { - v3_singleFetch: true; - } -} - -export default defineConfig({ - plugins: [ - remix({ - future: { - v3_fetcherPersist: true, - v3_relativeSplatPath: true, - v3_throwAbortReason: true, - v3_singleFetch: true, - v3_lazyRouteDiscovery: true, - }, - }), - tsconfigPaths(), - ], - define: { - // 在构建时为客户端代码提供 process.env.NODE_ENV 变量 - "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"), - }, - server: { - host: '0.0.0.0', - port: 5173, - open: true, - allowedHosts: ['nas.7bm.co', 'localhost', '127.0.0.1'], // 允许的主机名列表1 - cors: true, - // HMR配置 - hmr: { - // 控制HMR更新时行为 - overlay: false, - }, - }, - // 优化依赖预构建配置 - optimizeDeps: { - // 防止依赖预构建时触发页面刷新导致路由中断 - force: false, - // 预构建这些依赖,避免首次加载时出现重新构建 - include: ['react-pdf', 'pdfjs-dist', 'dayjs', '@remix-run/node', 'react-dom', 'axios', 'dayjs/plugin/utc', 'react-router-dom', 'jszip', 'ahooks', 'antd', 'immer', '@ant-design/icons', 'react-markdown', 'remark-math', 'remark-breaks', 'rehype-katex','remark-gfm'], - }, -}); +import { vitePlugin as remix } from "@remix-run/dev"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +declare module "@remix-run/node" { + interface Future { + v3_singleFetch: true; + } +} + +export default defineConfig({ + plugins: [ + remix({ + future: { + v3_fetcherPersist: true, + v3_relativeSplatPath: true, + v3_throwAbortReason: true, + v3_singleFetch: true, + v3_lazyRouteDiscovery: true, + }, + }), + tsconfigPaths(), + ], + define: { + // 在构建时为客户端代码提供 process.env.NODE_ENV 变量 + "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"), + }, + server: { + host: '0.0.0.0', + port: 5173, + // port: Number(process.env.PORT) || 5173, + open: true, + // open: false, + allowedHosts: ['nas.7bm.co', 'localhost', '127.0.0.1'], // 允许的主机名列表1 + cors: true, + // HMR配置 + hmr: { + // 控制HMR更新时行为 + overlay: false, + }, + }, + // 优化依赖预构建配置 + optimizeDeps: { + // 防止依赖预构建时触发页面刷新导致路由中断 + force: false, + // 预构建这些依赖,避免首次加载时出现重新构建 + include: ['react-pdf', 'pdfjs-dist', 'dayjs', '@remix-run/node', 'react-dom', 'axios', 'dayjs/plugin/utc', 'react-router-dom', 'jszip', 'ahooks', 'antd', 'immer', '@ant-design/icons', 'react-markdown', 'remark-math', 'remark-breaks', 'rehype-katex','remark-gfm'], + }, +});