添加登录内容,尚未完善,先创建分支
This commit is contained in:
@@ -301,7 +301,7 @@ export async function apiRequest<T>(
|
||||
|
||||
// 检查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 || '请求失败',
|
||||
|
||||
@@ -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<ContractStructureComparison[]>(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<string, Array<{ status: string; [key: string]: unknown }>>)['合同封面'][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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<T>(responseData: unknown): T | null {
|
||||
if (!responseData) return null;
|
||||
// /**
|
||||
// * 从不同格式的 API 响应中提取数据
|
||||
// * @param responseData API 响应数据
|
||||
// * @returns 提取后的数据或 null
|
||||
// */
|
||||
// function extractApiData<T>(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<string, string>
|
||||
};
|
||||
|
||||
// 根据排序方式设置排序
|
||||
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<string, string> = {};
|
||||
|
||||
// 处理文件类型筛选
|
||||
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<Document[]>('documents', params);
|
||||
|
||||
if (response.error) {
|
||||
return { error: response.error, status: response.status };
|
||||
}
|
||||
|
||||
// 提取API返回的数据
|
||||
const extractedDocuments = extractApiData<Document[]>(response.data);
|
||||
|
||||
if (!extractedDocuments) {
|
||||
return { error: '获取评查文件数据失败', status: 500 };
|
||||
}
|
||||
|
||||
// 从响应头中获取总数
|
||||
let totalCount = 0;
|
||||
const responseWithHeaders = response as { data: Document[]; headers: Record<string, string> };
|
||||
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<number, string> = {};
|
||||
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<ReviewFileFromSQL[]>('rpc/get_review_files_with_details', listParams),
|
||||
postgrestPost<number>('rpc/count_review_files', rpcParams)
|
||||
]);
|
||||
|
||||
if (!evaluationResultsResponse.error) {
|
||||
evaluationResults = extractApiData<EvaluationResult[]>(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<EvaluationPoint[]>(evaluationPointsResponse.data) || [];
|
||||
}
|
||||
// 处理获取总数的错误
|
||||
if (countResponse.error || typeof countResponse.data !== 'number') {
|
||||
console.error('获取文档总数失败:', countResponse.error);
|
||||
}
|
||||
|
||||
// 创建评查点ID到评查点数据的映射
|
||||
const pointsMap = new Map<string | number, EvaluationPoint>();
|
||||
evaluationPoints.forEach(point => {
|
||||
pointsMap.set(point.id, point);
|
||||
});
|
||||
const totalCount = typeof countResponse.data === 'number' ? countResponse.data : 0;
|
||||
|
||||
// 创建文档ID到评查结果列表的映射
|
||||
const documentResultsMap = new Map<string | number, EvaluationResult[]>();
|
||||
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<string | number, DocumentReviewResult>();
|
||||
// 存储每个文档的问题消息
|
||||
const documentIssuesMap = new Map<string | number, Array<{severity: 'info' | 'warning' | 'error' | 'critical', message: string}>>();
|
||||
// 存储每个文档的分数
|
||||
const documentScoreMap = new Map<string | number, number>();
|
||||
|
||||
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
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
+96
-112
@@ -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<DocumentUI> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端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<string, string>
|
||||
};
|
||||
|
||||
// 添加筛选条件
|
||||
const filter: Record<string, string> = {};
|
||||
|
||||
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<Document[]>('documents', params);
|
||||
|
||||
if (response.error) {
|
||||
return { error: response.error, status: response.status };
|
||||
}
|
||||
|
||||
// 提取数据
|
||||
const extractedData = extractApiData<Document[]>(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<string, string>
|
||||
};
|
||||
|
||||
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<DocumentFromSQL[], unknown>('rpc/get_documents_with_filters', { ...rpcParams, page, page_size: pageSize }),
|
||||
postgrestPost<number, unknown>('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) {
|
||||
|
||||
+205
-30
@@ -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<string, unknown>;
|
||||
comparison_results?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// 文件上传响应接口
|
||||
export interface FileUploadResponse {
|
||||
success: boolean;
|
||||
@@ -118,6 +131,7 @@ export async function uploadFileToBinary(file: File): Promise<ArrayBuffer> {
|
||||
* @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<Document[]>('documents', documentsParams),
|
||||
// postgrestGet<ContractStructureComparison[]>('contract_structure_comparison', comparisonParams)
|
||||
// ]);
|
||||
|
||||
const documentsResponse = await postgrestGet<Document[]>('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<Document[]>(documentsResponse.data);
|
||||
if (extractedDocuments) {
|
||||
documentsData = extractedDocuments;
|
||||
}
|
||||
}
|
||||
|
||||
// 提取contract_structure_comparison表数据并转换为Document格式
|
||||
// let comparisonData: Document[] = [];
|
||||
// if (!comparisonResponse.error && comparisonResponse.data) {
|
||||
// const extractedComparison = extractApiData<ContractStructureComparison[]>(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<Document[]>('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<ContractStructureComparison[]>('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<Document[]>(documentsResponse.data);
|
||||
if (extractedDocuments) {
|
||||
allData = [...allData, ...extractedDocuments];
|
||||
}
|
||||
};
|
||||
|
||||
const response = await postgrestGet<Document[]>('documents', params);
|
||||
|
||||
if (response.error) {
|
||||
return { error: response.error, status: response.status };
|
||||
}
|
||||
|
||||
const extractedData = extractApiData<Document[]>(response.data);
|
||||
if (!extractedData) {
|
||||
return { error: '获取数据失败', status: 500 };
|
||||
// 处理合同附件数据
|
||||
if (!attachmentResponse.error && attachmentResponse.data) {
|
||||
const extractedAttachments = extractApiData<ContractStructureComparison[]>(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 {
|
||||
|
||||
@@ -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: '新增评查点',
|
||||
|
||||
@@ -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<Set<string>>(new Set());
|
||||
const [filterType, setFilterType] = useState<FilterType>('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 (
|
||||
<div className="bg-white rounded-lg border border-gray-200 h-[calc(100vh-120px)] overflow-y-auto">
|
||||
<div className="review-panel-header py-2 px-4 flex items-center">
|
||||
<i className="ri-file-compare-line text-primary mr-2"></i>
|
||||
<span className="font-medium text-primary">结构比对(0)</span>
|
||||
</div>
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<i className="ri-file-search-line text-4xl mb-2 block"></i>
|
||||
<p>暂无结构比对数据</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="bg-white rounded-lg border border-gray-200 h-[calc(100vh-120px)] flex flex-col">
|
||||
{/* 固定头部 */}
|
||||
<div className="flex-shrink-0 text-sm font-medium">
|
||||
<div className="review-panel-header py-2 px-4 flex items-center border-b border-gray-100">
|
||||
<i className="ri-book-read-line text-primary mr-2"></i>
|
||||
<span className="font-medium text-primary">结构比对({totalFields})</span>
|
||||
</div>
|
||||
|
||||
{/* 固定统计概览和筛选按钮 */}
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
{/* 统计概览 */}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm mb-2">
|
||||
<button
|
||||
onClick={() => setFilterType('normal')}
|
||||
className={`text-center py-2 rounded flex items-center justify-center transition-all duration-200 ${
|
||||
filterType === 'normal'
|
||||
? 'bg-green-100 border-2 border-green-300'
|
||||
: 'bg-green-50 border-2 border-transparent hover:bg-green-100'
|
||||
}`}
|
||||
>
|
||||
<div className="text-green-600 mr-2">正常</div>
|
||||
<div className="font-medium text-green-600">{normalFields}</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setFilterType('abnormal')}
|
||||
className={`text-center py-2 rounded flex items-center justify-center transition-all duration-200 ${
|
||||
filterType === 'abnormal'
|
||||
? 'bg-red-100 border-2 border-red-300'
|
||||
: 'bg-red-50 border-2 border-transparent hover:bg-red-100'
|
||||
}`}
|
||||
>
|
||||
<div className="text-red-600 mr-2">异常</div>
|
||||
<div className="font-medium text-red-600">{abnormalFields}</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 筛选重置按钮 */}
|
||||
{filterType !== 'all' && (
|
||||
<button
|
||||
onClick={() => setFilterType('all')}
|
||||
className="w-full text-center py-1 text-xs text-gray-600 hover:text-gray-800 border border-gray-200 rounded hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
显示全部 ({totalFields})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 滚动内容区域 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{Object.keys(filteredResults).length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<i className="ri-filter-off-line text-2xl mb-2 block"></i>
|
||||
<p>没有找到{filterType === 'normal' ? '正常' : '异常'}的字段</p>
|
||||
</div>
|
||||
) : (
|
||||
Object.entries(filteredResults).map(([sectionName, fields]) => {
|
||||
const isExpanded = expandedSections.has(sectionName);
|
||||
const sectionAbnormalCount = fields.filter(field => field.status !== 'normal').length;
|
||||
|
||||
return (
|
||||
<div key={sectionName} className="border-b border-gray-100 last:border-b-0">
|
||||
{/* 章节头部 */}
|
||||
<button
|
||||
onClick={() => toggleSection(sectionName)}
|
||||
className="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center justify-between transition-colors"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<i className={`${isExpanded ? 'ri-arrow-down-s-line' : 'ri-arrow-right-s-line'} mr-2 text-gray-400 transition-transform`}></i>
|
||||
<span className="font-medium text-gray-900">{sectionName}</span>
|
||||
<span className="ml-2 text-sm text-gray-500">({fields.length})</span>
|
||||
{sectionAbnormalCount > 0 && filterType === 'all' && (
|
||||
<span className="ml-2 px-2 py-0.5 bg-red-100 text-red-600 text-xs rounded-full">
|
||||
{sectionAbnormalCount}异常
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 章节内容 */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-3">
|
||||
{fields.map((field, index) => {
|
||||
const statusStyle = getStatusStyle(field.status);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => 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'}`}
|
||||
>
|
||||
{/* 字段名和状态 */}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium text-gray-900">{field.field}</span>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${
|
||||
field.status === 'normal'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{field.status === 'normal' ? '正常' : '异常'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 详细说明 */}
|
||||
<p className="text-sm text-gray-600 mb-3">{field.details}</p>
|
||||
|
||||
{/* 页码信息 */}
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
{field.source_page ? (
|
||||
<div className="flex items-center text-blue-600">
|
||||
<i className="ri-file-text-line mr-1"></i>
|
||||
主文件: 第{field.source_page}页
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center text-gray-400">
|
||||
<i className="ri-file-text-line mr-1"></i>
|
||||
主文件: 未找到内容
|
||||
</div>
|
||||
)}
|
||||
|
||||
{field.template_page ? (
|
||||
<div className="flex items-center text-purple-600">
|
||||
<i className="ri-file-copy-line mr-1"></i>
|
||||
模板: 第{field.template_page}页
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center text-gray-400">
|
||||
<i className="ri-file-copy-line mr-1"></i>
|
||||
模板: 未找到内容
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部说明 */}
|
||||
{/* <div className="flex-shrink-0 px-4 py-3 bg-gray-50 text-xs text-gray-500 rounded-b-lg border-t border-gray-100">
|
||||
<div className="flex items-center">
|
||||
<i className="ri-information-line mr-1"></i>
|
||||
点击字段框可同时跳转到主文件和模板对应页面
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="text-red-500 p-4">
|
||||
<p>无法加载文件:合同模板未上传</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="text-red-500 p-4">
|
||||
<p>无法加载文件:路径无效</p>
|
||||
@@ -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
|
||||
}}
|
||||
>
|
||||
<Document
|
||||
file={DOCUMENT_URL+fileContent.path}
|
||||
file={DOCUMENT_URL+real_path}
|
||||
onLoadSuccess={onDocumentLoadSuccess}
|
||||
onLoadError={(error) => {
|
||||
console.error("PDF加载错误:", error);
|
||||
@@ -457,39 +469,41 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage
|
||||
|
||||
return (
|
||||
<div className="file-preview">
|
||||
<div className="file-preview-header py-2 px-4 text-xs sm:text-xs md:text-sm max-w-full text-overflow-ellipsis">
|
||||
<div className="flex items-center">
|
||||
<i className={`${isStructuredView ? 'ri-file-list-line' : 'ri-file-text-line'} text-primary mr-2`}></i>
|
||||
<span className="font-medium text-primary">{isStructuredView ? '附件预览' : '文件预览'}</span>
|
||||
<div className="file-preview-header px-2 text-xs sm:text-xs md:text-sm max-w-full flex items-center justify-between min-w-0">
|
||||
<div className="flex items-center min-w-0 flex-shrink-0">
|
||||
<i className={`${isStructuredView ? 'ri-file-list-line' : 'ri-file-text-line'} text-primary mr-2 flex-shrink-0`}></i>
|
||||
<span className="font-medium text-primary truncate max-w-[120px]" title={isStructuredView ? '模板预览' : '文件预览'}>
|
||||
{isStructuredView ? '模板预览' : '文件预览'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="file-preview-actions flex items-center">
|
||||
<div className="file-preview-actions flex items-center ml-2 min-w-0 flex-1 justify-end overflow-hidden">
|
||||
<button
|
||||
className="ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5"
|
||||
className="ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5 flex-shrink-0"
|
||||
onClick={handleScrollToTop}
|
||||
title="返回顶部"
|
||||
>
|
||||
<i className="ri-arrow-up-double-line"></i>
|
||||
<span className="ml-1 sm:inline md:inline lg:hidden xl:inline">返回顶部</span>
|
||||
<span className="ml-1 hidden sm:hidden md:hidden lg:hidden xl:inline truncate max-w-[60px]">返回顶部</span>
|
||||
</button>
|
||||
<button
|
||||
className="ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5"
|
||||
className="ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5 flex-shrink-0"
|
||||
onClick={handleZoomIn}
|
||||
title="放大"
|
||||
>
|
||||
<i className="ri-zoom-in-line"></i>
|
||||
</button>
|
||||
<button
|
||||
className="ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5"
|
||||
className="ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5 flex-shrink-0"
|
||||
onClick={handleZoomOut}
|
||||
title="缩小"
|
||||
>
|
||||
<i className="ri-zoom-out-line"></i>
|
||||
</button>
|
||||
{/* 页码跳转控件 */}
|
||||
<div className="inline-flex items-center ml-2">
|
||||
<div className="inline-flex items-center ml-2 flex-shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
className="ant-input ant-input-sm py-0 px-1 text-xs max-h-6 leading-5 max-w-[2.5rem] text-center
|
||||
className="ant-input ant-input-sm py-0 px-1 text-xs max-h-6 leading-5 w-[2.5rem] text-center
|
||||
focus:outline-none focus:ring-1 focus:ring-green-900"
|
||||
placeholder="页码"
|
||||
value={pageInputValue}
|
||||
@@ -504,17 +518,25 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage
|
||||
>
|
||||
<i className="ri-arrow-right-line"></i>
|
||||
</button>
|
||||
{numPages && <span className="ml-1 text-xs text-gray-500 sm:inline md:inline lg:hidden xl:inline">/ {numPages}</span>}
|
||||
{numPages && (
|
||||
<span className="ml-1 text-xs text-gray-500 hidden sm:hidden md:hidden lg:hidden xl:inline whitespace-nowrap">
|
||||
/ {numPages}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="ml-2 text-xs text-gray-500 inline-block sm:inline md:inline lg:hidden xl:inline">{"比例:"+zoomLevel+"%"}</span>
|
||||
<span className="ml-2 text-xs text-gray-500 hidden sm:hidden md:hidden lg:hidden xl:inline whitespace-nowrap flex-shrink-0">
|
||||
比例:{zoomLevel}%
|
||||
</span>
|
||||
<button
|
||||
className={`ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5 ml-2 ${dragMode ? 'active bg-green-300' : ''}`}
|
||||
className={`ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5 ml-2 flex-shrink-0 ${dragMode ? 'active bg-green-300' : ''}`}
|
||||
title="切换拖拽模式"
|
||||
aria-pressed={dragMode}
|
||||
onClick={toggleDragMode}
|
||||
>
|
||||
<i className="ri-drag-move-line"></i>
|
||||
<span className="ml-1 sm:inline md:inline lg:hidden xl:inline">拖拽模式{dragMode ? '(已激活)' : ''}</span>
|
||||
<span className="ml-1 hidden sm:hidden md:hidden lg:hidden xl:inline truncate max-w-[80px]">
|
||||
拖拽模式{dragMode ? '(已激活)' : ''}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<File[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const uploadAreaRef = useRef<UploadAreaRef>(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 (
|
||||
<div className="tab-container w-full flex-1">
|
||||
@@ -105,9 +238,7 @@ export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfi
|
||||
>
|
||||
<i className="ri-lightbulb-line"></i> AI智能分析
|
||||
</button> */}
|
||||
{/* {fileInfo.type === '1' && ( */}
|
||||
{/* 隐藏结构比对 */}
|
||||
{fileInfo.type === '999999' && (
|
||||
{fileInfo.type === '1' && (
|
||||
<button
|
||||
className={`tab-nav-item ${activeTab === 'filecompare' ? 'active' : ''}`}
|
||||
onClick={() => onTabChange('filecompare')}
|
||||
@@ -128,6 +259,15 @@ export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfi
|
||||
</div>
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex space-x-3">
|
||||
{/* 重新上传 */}
|
||||
{activeTab === 'filecompare' && (
|
||||
<button
|
||||
className="ant-btn ant-btn-default flex items-center my-2 mr-4"
|
||||
onClick={handleOpenReuploadModal}
|
||||
>
|
||||
<i className="ri-refresh-line mr-1"></i> 重新上传模板
|
||||
</button>
|
||||
)}
|
||||
{/* 返回上一级 */}
|
||||
<button
|
||||
className="ant-btn ant-btn-default flex items-center my-2"
|
||||
@@ -160,6 +300,100 @@ export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfi
|
||||
<div className="tab-content w-full">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* 重新上传模板模态框 */}
|
||||
<Modal
|
||||
isOpen={isReuploadModalOpen}
|
||||
onClose={handleCloseReuploadModal}
|
||||
title="重新上传模板"
|
||||
size="medium"
|
||||
footer={
|
||||
<div className="flex justify-end space-x-3">
|
||||
<Button
|
||||
type="default"
|
||||
onClick={handleCloseReuploadModal}
|
||||
disabled={isUploading}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleConfirmUpload}
|
||||
disabled={selectedTemplateFiles.length === 0 || isUploading}
|
||||
icon={isUploading ? 'ri-loader-4-line animate-spin' : undefined}
|
||||
>
|
||||
{isUploading ? '上传中...' : '确定上传'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-600 mb-4">
|
||||
<p>请选择新的模板文件用于结构比对。</p>
|
||||
<p className="mt-2 text-orange-600">
|
||||
<i className="ri-information-line mr-1"></i>
|
||||
注意:只支持PDF格式的文件,上传后将替换当前的比对模板。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UploadArea
|
||||
ref={uploadAreaRef}
|
||||
onFilesSelected={handleTemplateFilesSelected}
|
||||
accept=".pdf,application/pdf"
|
||||
multiple={false}
|
||||
icon="ri-file-pdf-line"
|
||||
buttonText="选择模板文件"
|
||||
mainText="点击或拖拽PDF文件到此区域"
|
||||
tipText={
|
||||
<span className="text-xs text-gray-500">
|
||||
支持格式:PDF
|
||||
</span>
|
||||
}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
|
||||
{/* 已选择的文件列表 */}
|
||||
{selectedTemplateFiles.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">已选择的文件:</h4>
|
||||
<div className="space-y-2">
|
||||
{selectedTemplateFiles.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<i className="ri-file-pdf-line text-red-500 mr-2"></i>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{file.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatFileSize(file.size)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="text-gray-400 hover:text-red-500 transition-colors"
|
||||
onClick={() => {
|
||||
setSelectedTemplateFiles(prev =>
|
||||
prev.filter((_, i) => i !== index)
|
||||
);
|
||||
if (uploadAreaRef.current) {
|
||||
uploadAreaRef.current.resetFileInput();
|
||||
}
|
||||
}}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<i className="ri-close-line"></i>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
export { FileDetails } from './FileDetails';
|
||||
export { Comparison } from './Comparison';
|
||||
@@ -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<string, ApiConfig> = {
|
||||
// 开发环境
|
||||
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<string, ApiConfig> = {
|
||||
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<string, ApiConfig> = {
|
||||
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<string, ApiConfig> = {
|
||||
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;
|
||||
|
||||
// 导出所有配置,供调试使用
|
||||
|
||||
+21
-3
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">正在处理登录...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
<h1>交叉评查</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Outlet />
|
||||
)
|
||||
}
|
||||
@@ -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`);
|
||||
};
|
||||
|
||||
+229
-221
@@ -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<FileUploadResponse> {
|
||||
// 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<boolean>(false);
|
||||
// const [contractMainFiles, setContractMainFiles] = useState<File[]>([]);
|
||||
// const [contractAttachmentFiles, setContractAttachmentFiles] = useState<File[]>([]);
|
||||
const [isContractType, setIsContractType] = useState<boolean>(false);
|
||||
const [contractMainFiles, setContractMainFiles] = useState<File[]>([]);
|
||||
const [contractAttachmentFiles, setContractAttachmentFiles] = useState<File[]>([]);
|
||||
|
||||
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<UploadedFile[]>([]);
|
||||
|
||||
// 计时器引用
|
||||
const progressIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// 计时器引用 - 分离为三个独立的定时器
|
||||
const uploadProgressIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const processingStatusIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const queueStatusIntervalRef = useRef<NodeJS.Timeout | null>(null); // 原 statusCheckIntervalRef
|
||||
|
||||
// UploadArea组件引用
|
||||
const uploadAreaRef = useRef<UploadAreaRef>(null);
|
||||
@@ -451,69 +428,122 @@ export default function FilesUpload() {
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
// 状态检查定时器引用
|
||||
const statusCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 添加组件挂载状态引用
|
||||
const isMountedRef = useRef<boolean>(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<FileUploadResponse>((_, 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) => (
|
||||
<div className="flex items-center">
|
||||
<i className={`${record.name.includes('.pdf') ? 'ri-file-pdf-line text-red-500' : 'ri-file-word-2-line text-blue-500'} mr-2 text-lg`}></i>
|
||||
<span className="truncate">{record.name}</span>
|
||||
<i className={`${record.name?.includes('.pdf') ? 'ri-file-pdf-line text-red-500' : 'ri-file-word-2-line text-blue-500'} mr-2 text-lg`}></i>
|
||||
<span className="truncate">{record.name || '未知文件'}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@@ -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 (
|
||||
<div className="file-upload-page">
|
||||
@@ -1688,7 +1694,7 @@ export default function FilesUpload() {
|
||||
{/* 自定义标题栏 */}
|
||||
<div className="w-full flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium">文件上传</h3>
|
||||
{/* {isContractType && uploadStage === "idle" && (
|
||||
{isContractType && uploadStage === "idle" && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon="ri-upload-cloud-line"
|
||||
@@ -1696,15 +1702,14 @@ export default function FilesUpload() {
|
||||
>
|
||||
开始上传
|
||||
</Button>
|
||||
)} */}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 初始上传区域 */}
|
||||
{uploadStage === "idle" && (
|
||||
<>
|
||||
{/* {!isContractType ? ( */}
|
||||
{/* {true ? ( */}
|
||||
{/* // 标准上传区域 - 非合同类型 */}
|
||||
{!isContractType ? (
|
||||
// 标准上传区域 - 非合同类型
|
||||
<UploadArea
|
||||
ref={uploadAreaRef}
|
||||
onFilesSelected={handleFilesSelected}
|
||||
@@ -1713,53 +1718,53 @@ export default function FilesUpload() {
|
||||
tipText="支持单个或多个pdf文件上传,文件格式:PDF"
|
||||
shouldPreventFileSelect={!fileType}
|
||||
/>
|
||||
{/* ) : ( */}
|
||||
{/* 合同文件上传区域 - 双区域并排 */}
|
||||
{/* <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
) : (
|
||||
// 合同文件上传区域 - 双区域并排
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">合同主文件</h4>
|
||||
<UploadArea
|
||||
// onFilesSelected={handleContractMainFilesSelected}
|
||||
// ref={contractMainFileRef}
|
||||
// multiple={false}
|
||||
// accept=".pdf"
|
||||
// tipText="请上传合同主文件,格式:PDF"
|
||||
// mainText="上传合同主文件"
|
||||
// buttonText="选择主文件"
|
||||
// icon="ri-file-text-line"
|
||||
// shouldPreventFileSelect={!fileType}
|
||||
// />
|
||||
// {contractMainFiles.length > 0 && (
|
||||
// <div className="mt-2 text-sm text-green-600">
|
||||
// <i className="ri-checkbox-circle-line"></i>
|
||||
// 已选择主文件: <span className="font-medium">{contractMainFiles[0].name}</span>
|
||||
// </div>
|
||||
// )}
|
||||
// </div>
|
||||
// <div>
|
||||
// <h4 className="font-medium mb-2">合同附件</h4>
|
||||
// <UploadArea
|
||||
// onFilesSelected={handleContractAttachmentFilesSelected}
|
||||
// ref={contractAttachmentFileRef}
|
||||
// multiple={false}
|
||||
// accept=".pdf"
|
||||
// tipText="请上传合同附件,格式:PDF"
|
||||
// mainText="上传合同附件"
|
||||
// buttonText="选择附件"
|
||||
// icon="ri-file-copy-line"
|
||||
// shouldPreventFileSelect={!fileType}
|
||||
// />
|
||||
// {contractAttachmentFiles.length > 0 && (
|
||||
// <div className="mt-2 text-sm text-green-600">
|
||||
// <i className="ri-checkbox-circle-line"></i>
|
||||
// 已选择附件: {contractAttachmentFiles.map((file, index) => (
|
||||
// <span key={index} className="font-medium">{file.name}</span>
|
||||
// ))}
|
||||
// </div>
|
||||
// )}
|
||||
// </div>
|
||||
// </div>
|
||||
// )}
|
||||
onFilesSelected={handleContractMainFilesSelected}
|
||||
ref={contractMainFileRef}
|
||||
multiple={false}
|
||||
accept=".pdf"
|
||||
tipText="请上传合同主文件,格式:PDF"
|
||||
mainText="上传合同主文件"
|
||||
buttonText="选择主文件"
|
||||
icon="ri-file-text-line"
|
||||
shouldPreventFileSelect={!fileType}
|
||||
/>
|
||||
{contractMainFiles.length > 0 && (
|
||||
<div className="mt-2 text-sm text-green-600">
|
||||
<i className="ri-checkbox-circle-line"></i>
|
||||
已选择主文件: <span className="font-medium">{contractMainFiles[0].name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">合同附件</h4>
|
||||
<UploadArea
|
||||
onFilesSelected={handleContractAttachmentFilesSelected}
|
||||
ref={contractAttachmentFileRef}
|
||||
multiple={false}
|
||||
accept=".pdf"
|
||||
tipText="请上传合同附件,格式:PDF"
|
||||
mainText="上传合同附件"
|
||||
buttonText="选择附件"
|
||||
icon="ri-file-copy-line"
|
||||
shouldPreventFileSelect={!fileType}
|
||||
/>
|
||||
{contractAttachmentFiles.length > 0 && (
|
||||
<div className="mt-2 text-sm text-green-600">
|
||||
<i className="ri-checkbox-circle-line"></i>
|
||||
已选择附件: {contractAttachmentFiles.map((file, index) => (
|
||||
<span key={index} className="font-medium">{file.name}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 测试文档标记 */}
|
||||
<div className="switch-container mb-4">
|
||||
@@ -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();
|
||||
|
||||
+93
-140
@@ -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<UserRole>("common");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const actionData = useActionData<typeof action>();
|
||||
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 (
|
||||
<div className="login-page">
|
||||
<div className="login-container">
|
||||
<div className="login-header">
|
||||
{/* <img src="/logo.png" alt="中国烟草" className="login-logo" /> */}
|
||||
<h1 className="login-title">中国烟草AI合同及卷宗审核系统</h1>
|
||||
</div>
|
||||
|
||||
<div className="login-form-container">
|
||||
<h2 className="login-subtitle">用户登录</h2>
|
||||
<Form
|
||||
method="post"
|
||||
className="login-form"
|
||||
>
|
||||
{actionData?.error && (
|
||||
<div className="error-message-container">
|
||||
<div className="error-icon"><i className="ri-error-warning-line"></i></div>
|
||||
<div className="error-text">{actionData.error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
<h2 className="login-subtitle">统一身份认证登录</h2>
|
||||
|
||||
{error && (
|
||||
<div className="error-message-container">
|
||||
<div className="error-icon"><i className="ri-error-warning-line"></i></div>
|
||||
<div className="error-text">{getErrorMessage(error)}</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">密码</label>
|
||||
<div className="password-input-container">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
id="password"
|
||||
name="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="form-input password-input"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="password-toggle-btn"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
aria-label={showPassword ? "隐藏密码" : "显示密码"}
|
||||
>
|
||||
<i className={showPassword ? "ri-eye-off-line" : "ri-eye-line"}></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="userRole">用户角色</label>
|
||||
<select
|
||||
id="userRole"
|
||||
name="userRole"
|
||||
value={userRole}
|
||||
onChange={(e) => setUserRole(e.target.value as UserRole)}
|
||||
className="form-input"
|
||||
required
|
||||
>
|
||||
<option value="common">普通用户</option>
|
||||
<option value="developer">管理员</option>
|
||||
</select>
|
||||
)}
|
||||
|
||||
<div className="oauth-login-section">
|
||||
<div className="login-description">
|
||||
<p>请点击下方按钮进行统一身份认证登录</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="login-button"
|
||||
onClick={handleOAuthLogin}
|
||||
className="oauth-login-button"
|
||||
type="button"
|
||||
>
|
||||
登录
|
||||
<i className="ri-shield-user-line"></i>
|
||||
统一身份认证登录
|
||||
</button>
|
||||
</Form>
|
||||
|
||||
<div className="login-tips">
|
||||
<p>
|
||||
<i className="ri-information-line"></i>
|
||||
系统将跳转到统一身份认证平台进行登录
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="login-footer">
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">正在退出登录...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+37
-14
@@ -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<typeof loader>();
|
||||
const { document, reviewPoints, statistics, reviewInfo } = loaderData;
|
||||
const { document, reviewPoints, statistics, reviewInfo, comparison_document } = loaderData;
|
||||
const [isLoading, setIsLoading] = useState(false); // 已经通过loader加载了数据,不需要再显示加载状态
|
||||
const [activeTab, setActiveTab] = useState<string>('preview'); // 'preview', 'analysis', 'fileinfo'
|
||||
const [reviewData, setReviewData] = useState<ReviewData | null>(null);
|
||||
const [activeReviewPointResultId, setActiveReviewPointResultId] = useState<string | null>(null);
|
||||
const [targetPage, setTargetPage] = useState<number | undefined>(undefined);
|
||||
const [templateTargetPage, setTemplateTargetPage] = useState<number | undefined>(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' && (
|
||||
<div className="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:space-x-4">
|
||||
{/* 左侧:原文件预览 */}
|
||||
<div className="w-full lg:w-[38%]">
|
||||
<div className={`w-full ${comparison_document.template_contract_path ? 'lg:w-[38%]' : 'lg:w-[56%]'}`}>
|
||||
<FilePreview
|
||||
fileContent={document}
|
||||
reviewPoints={reviewData.reviewPoints}
|
||||
@@ -631,24 +635,43 @@ export default function ReviewDetails() {
|
||||
</div>
|
||||
|
||||
{/* 中间:附件文件预览 */}
|
||||
<div className="w-full lg:w-[38%]">
|
||||
<div className={`w-full ${comparison_document.template_contract_path ? 'lg:w-[38%]' : 'lg:w-[20%]'}`}>
|
||||
<FilePreview
|
||||
fileContent={document}
|
||||
fileContent={comparison_document}
|
||||
reviewPoints={[]}
|
||||
activeReviewPointResultId={activeReviewPointResultId}
|
||||
targetPage={targetPage}
|
||||
targetPage={templateTargetPage}
|
||||
isStructuredView={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右侧:评查结果 */}
|
||||
{/* 右侧:结构比较结果 */}
|
||||
<div className="w-full lg:w-[24%]">
|
||||
<ReviewPointsList
|
||||
reviewPoints={reviewData.reviewPoints}
|
||||
statistics={reviewData.statistics}
|
||||
activeReviewPointResultId={activeReviewPointResultId}
|
||||
onReviewPointSelect={handleReviewPointSelect}
|
||||
onStatusChange={handleReviewPointStatusChange}
|
||||
<Comparison
|
||||
comparison_document={comparison_document}
|
||||
onPageJump={(sourcePage, templatePage) => {
|
||||
// 同时处理主文件和模板文件的页码跳转
|
||||
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}页`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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';
|
||||
|
||||
+131
-91
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<TokenResponse | null> {
|
||||
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<UserInfoResponse | null> {
|
||||
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<boolean> {
|
||||
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<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -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://<u>10.79.112.85</u>/oauth/authorize?response_type=code&scope=read&client_id=<u>54d2a619fe5c81ae1250434c441fccccqMtKwh7H4fO</u>&redirect_uri=<u>http%3a%2f%2f10.79.97.17%2f</u>&state=<u>10ff0be64971c07f893afc332877f68arS8FH2iyZni</u>
|
||||
|
||||
#### 🔗 请求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平台版本而有所差异,请以实际平台配置为准。
|
||||
+49
-47
@@ -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'],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user