From 4843b7bebf7b9ca456acc46ee8e587b640b28917 Mon Sep 17 00:00:00 2001 From: yorn <1057707203@qq.com> Date: Wed, 16 Jul 2025 22:20:02 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BA=A4=E5=8F=89=E8=AF=84?= =?UTF-8?q?=E6=9F=A5=E8=AF=A6=E6=83=85=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- OAuth2.0登录数据流程说明.md | 274 ++ app/api/cross-checking/cross-file-result.ts | 130 + app/api/evaluation_points/reviews.ts | 41 +- app/components/cross-checking/FileInfo.tsx | 91 + app/components/cross-checking/FilePreview.tsx | 599 +++++ .../cross-checking/ReviewPointsList.tsx | 2285 +++++++++++++++++ app/components/cross-checking/index.ts | 8 + app/routes/cross-checking._index.tsx | 3 +- app/routes/cross-checking.result.tsx | 733 ++++++ app/styles/cross-checking-result.css | 338 +++ sql/document_types.sql | 58 + sql/documents.sql | 96 + sql/evaluation_points.sql | 83 + sql/evaluation_results.sql | 63 + sql/scoring_proposals.sql | 75 + sql/update_get_review_files_with_details.sql | 82 + tmp/交叉评查-提出意见.png | Bin 0 -> 322739 bytes tmp/交叉评查上传文件页面.png | Bin 0 -> 115572 bytes tmp/交叉评查任务列表.png | Bin 0 -> 174518 bytes 19 files changed, 4955 insertions(+), 4 deletions(-) create mode 100644 OAuth2.0登录数据流程说明.md create mode 100644 app/api/cross-checking/cross-file-result.ts create mode 100644 app/components/cross-checking/FileInfo.tsx create mode 100644 app/components/cross-checking/FilePreview.tsx create mode 100644 app/components/cross-checking/ReviewPointsList.tsx create mode 100644 app/components/cross-checking/index.ts create mode 100644 app/routes/cross-checking.result.tsx create mode 100644 app/styles/cross-checking-result.css create mode 100644 sql/document_types.sql create mode 100644 sql/documents.sql create mode 100644 sql/evaluation_points.sql create mode 100644 sql/evaluation_results.sql create mode 100644 sql/scoring_proposals.sql create mode 100644 sql/update_get_review_files_with_details.sql create mode 100644 tmp/交叉评查-提出意见.png create mode 100644 tmp/交叉评查上传文件页面.png create mode 100644 tmp/交叉评查任务列表.png diff --git a/OAuth2.0登录数据流程说明.md b/OAuth2.0登录数据流程说明.md new file mode 100644 index 0000000..8a7cf97 --- /dev/null +++ b/OAuth2.0登录数据流程说明.md @@ -0,0 +1,274 @@ +# OAuth2.0 登录数据流程说明 + +## 📋 概述 + +本文档详细说明了中国烟草AI合同及卷宗审核系统中OAuth2.0登录的完整数据流程,从用户点击登录按钮到最终完成身份认证的全过程。 + +## 🔄 完整流程图 + +```mermaid +sequenceDiagram + participant User as 用户浏览器 + participant LoginPage as 登录页面(/login) + participant IDaaS as IDaaS认证服务器 + participant CallbackPage as 回调页面(/callback) + participant Session as 会话管理 + participant MainApp as 主应用 + + Note over User,MainApp: 1. 用户发起登录 + User->>LoginPage: 访问登录页面 + LoginPage->>User: 显示登录界面 + User->>LoginPage: 点击"统一身份认证登录" + + Note over User,MainApp: 2. 生成授权URL并跳转 + LoginPage->>LoginPage: generateState() 生成随机状态值 + LoginPage->>LoginPage: localStorage保存oauth_state + LoginPage->>LoginPage: 构建授权URL + LoginPage->>User: window.location.href = authorizeUrl + + Note over User,MainApp: 3. IDaaS认证流程 + User->>IDaaS: 跳转到IDaaS登录页面 + IDaaS->>User: 显示统一登录界面 + User->>IDaaS: 输入用户名密码完成认证 + IDaaS->>User: 重定向回应用 (带code和state) + + Note over User,MainApp: 4. 授权码处理 + User->>CallbackPage: 访问/callback?code=xxx&state=yyy + CallbackPage->>CallbackPage: 验证state参数 + CallbackPage->>IDaaS: POST /oauth/token (用code换token) + IDaaS-->>CallbackPage: 返回access_token等信息 + + Note over User,MainApp: 5. 获取用户信息 + CallbackPage->>IDaaS: GET /userinfo (带access_token) + IDaaS-->>CallbackPage: 返回用户详细信息 + + Note over User,MainApp: 6. 创建本地会话 + CallbackPage->>Session: 创建用户会话 + Session->>Session: 保存token、用户信息等 + CallbackPage->>User: 重定向到主应用页面 + User->>MainApp: 成功访问应用 +``` + +## 🔍 详细步骤分析 + +### 步骤1: 用户访问登录页面 +**文件**: `app/routes/login.tsx` + +- 用户访问需要认证的页面时,系统检测到未登录状态 +- 重定向到 `/login` 页面 +- 登录页面加载时会检查OAuth配置是否完整 + +### 步骤2: 点击登录按钮 +**触发函数**: `handleOAuthLogin()` + +```typescript +// 关键操作流程 +1. 创建 OAuthClient 实例 +2. 调用 generateState() 生成随机状态值 (格式: randomString_idp) +3. 将状态值保存到 localStorage +4. 调用 getAuthorizeUrl(state) 构建授权URL +5. 通过 window.location.href 跳转到IDaaS +``` + +**构建的授权URL格式**: +``` +http://idaas-server/oauth/authorize? + response_type=code& + scope=read& + client_id=YOUR_CLIENT_ID& + redirect_uri=http://your-app/callback& + state=randomString_idp +``` + +### 步骤3: IDaaS认证服务器处理 +**外部系统**: IDaaS平台 + +- 用户在IDaaS页面输入账号密码 +- IDaaS验证用户身份 +- 认证成功后生成授权码(code) +- 重定向回应用的回调地址,携带code和state参数 + +### 步骤4: 回调页面处理授权码 +**文件**: `app/routes/callback.tsx` +**函数**: `loader()` + +#### 4.1 参数验证 +```typescript +// 检查错误参数 +if (error) { + return redirect(`/login?error=${error}`); +} + +// 检查授权码 +if (!code) { + return redirect("/login?error=missing_code"); +} + +// 验证状态值 +if (!state || !state.endsWith("_idp")) { + return redirect("/login?error=invalid_state"); +} +``` + +#### 4.2 换取访问令牌 +**调用**: `oauthClient.getAccessToken(code)` + +```typescript +// 发送POST请求到IDaaS +URL: http://idaas-server/oauth/token +Method: POST +Content-Type: application/x-www-form-urlencoded +Body: { + grant_type: 'authorization_code', + code: '从回调URL获取的code', + client_id: 'OAuth应用ID', + client_secret: 'OAuth应用密钥', + redirect_uri: '回调地址' +} +``` + +**响应数据**: +```json +{ + "access_token": "eyJhbGciO...", + "token_type": "bearer", + "refresh_token": "eyJhbGciOiJIUzI1...", + "expires_in": 7199, + "scope": "read", + "jti": "唯一标识" +} +``` + +### 步骤5: 获取用户信息 +**调用**: `oauthClient.getUserInfo(access_token)` + +```typescript +// 发送GET请求获取用户信息 +URL: http://idaas-server/api/bff/v1.2/oauth2/userinfo +Method: GET +Headers: { + 'Authorization': 'Bearer access_token' +} +``` + +**响应数据**: +```json +{ + "success": true, + "code": "200", + "data": { + "sub": "用户唯一标识", + "ou_id": "组织ID", + "nickname": "用户昵称", + "phone_number": "手机号", + "ou_name": "组织名称", + "email": "邮箱", + "username": "用户名" + } +} +``` + +### 步骤6: 创建本地会话 +**会话存储**: Remix的Cookie Session Storage + +```typescript +// 保存到会话中的信息 +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); // 用户信息 +session.set("userRole", userRole); // 用户角色 +``` + +### 步骤7: 重定向到目标页面 +- 获取原始请求的重定向URL (如果有) +- 设置会话Cookie +- 重定向到目标页面或首页 + +## 🔧 关键技术细节 + +### 状态值(State)安全机制 +- **生成**: 使用随机字符串 + "_idp" 后缀 +- **存储**: 保存到浏览器localStorage +- **验证**: 回调时检查state参数是否以"_idp"结尾 +- **作用**: 防止CSRF攻击 + +### Token管理策略 +- **存储位置**: 服务器端Session (Cookie) +- **安全性**: HttpOnly Cookie,防止XSS攻击 +- **过期设置**: Session过期时间与OAuth Token同步 (2小时) +- **自动刷新**: 提前5分钟自动刷新Token,用户无感知 +- **刷新机制**: 使用refresh_token实现令牌自动续期 +- **容错处理**: 刷新失败时自动清理session,重定向登录 + +### 错误处理机制 +- **参数缺失**: missing_code, invalid_state +- **网络错误**: token_error, userinfo_error +- **处理失败**: callback_error +- **用户友好**: 所有错误都转换为中文提示 + +### 用户角色判断 +```typescript +// 根据用户名判断角色 (可自定义业务逻辑) +const userRole = userInfo.data.username === "admin" ? "developer" : "common"; +``` + +## 🚀 后续操作说明 + +### 访问受保护资源 +用户登录成功后,后续的页面访问都会: +1. 从会话中检查 `isAuthenticated` 状态 +2. **自动检测Token状态**: 验证访问令牌是否过期或即将过期 +3. **智能刷新机制**: 如果Token将在5分钟内过期,自动使用refresh_token刷新 +4. **无感知更新**: Token刷新过程对用户完全透明,会话自动延长 +5. **权限控制**: 根据用户角色控制页面访问权限 + +### Token自动刷新流程 +```mermaid +graph TD + A[用户访问页面] --> B[检查Session中的Token] + B --> C{Token是否存在?} + C -->|否| D[重定向到登录页] + C -->|是| E[检查Token状态] + E --> F{Token即将过期?} + F -->|否| G[正常访问页面] + F -->|是| H[使用refresh_token刷新] + H --> I{刷新成功?} + I -->|是| J[更新Session中的Token] + I -->|否| K[清理Session并重定向登录] + J --> G +``` + +### 单点登出 +当用户登出时,系统会: +1. 调用IDaaS的登出接口 +2. 清理本地会话数据 +3. 重定向到登录页面 + +## ⚡ 性能优化建议 + +### ✅ 已实现的优化 +1. **智能令牌刷新**: ✅ 提前5分钟自动刷新,避免用户感知中断 +2. **Session同步**: ✅ Session过期时间与Token同步 (2小时) +3. **统一Token管理**: ✅ 使用TokenManager统一处理令牌逻辑 +4. **错误容错**: ✅ 刷新失败时优雅降级到重新登录 + +### 🔄 可进一步优化 +1. **缓存策略**: 用户信息可在会话期间缓存,减少重复请求 +2. **错误重试**: 网络请求失败时实现重试机制 +3. **状态持久化**: 考虑将部分状态信息持久化到数据库 +4. **Token预刷新**: 可以在页面加载时检查Token状态并预刷新 + +## 🔒 安全注意事项 + +1. **HTTPS传输**: 生产环境必须使用HTTPS +2. **密钥保护**: client_secret必须妥善保存在服务器端 +3. **状态验证**: 严格验证state参数防止CSRF +4. **令牌保护**: 访问令牌不应暴露给前端JavaScript +5. **会话安全**: 使用安全的Cookie配置 + +--- + +**总结**: 整个OAuth2.0登录流程遵循标准的授权码模式,通过多步验证确保用户身份的安全性,同时提供良好的用户体验和错误处理机制。 \ No newline at end of file diff --git a/app/api/cross-checking/cross-file-result.ts b/app/api/cross-checking/cross-file-result.ts new file mode 100644 index 0000000..c6d8454 --- /dev/null +++ b/app/api/cross-checking/cross-file-result.ts @@ -0,0 +1,130 @@ +import { postgrestPost } from "../postgrest-client"; +import { API_BASE_URL } from "../../config/api-config"; + +/** + * 提出意见的请求参数接口 + */ +export interface SubmitOpinionRequest { + reviewPointResultId: string | number; + documentId: string | number; + auditPoint: string; + foundIssue: string; + auditOpinion: string; + deductionScore: number; +} + +/** + * 提出意见的响应接口 + */ +export interface SubmitOpinionResponse { + success: boolean; + message: string; + data?: { + id: string | number; + created_at: string; + }; +} + +/** + * 交叉评查意见数据接口 + */ +export interface CrossCheckingOpinion { + id: string | number; + evaluation_point_id: string | number; + document_id: string | number; + audit_point: string; + found_issue: string; + audit_opinion: string; + deduction_score: number; + status: string; + created_at: string; + updated_at?: string; +} + +/** + * API响应格式 + */ +export interface ApiResponse { + data?: T; + error?: string; + status?: number; +} + +/** + * 提交交叉评查意见 + * @param opinionData 意见数据 + * @returns 提交结果 + */ +export async function submitCrossCheckingOpinion( + opinionData: SubmitOpinionRequest +): Promise> { + try { + const requestData = { + proposer_user_id: 1, + evaluation_result_id: opinionData.reviewPointResultId, + // document_id: opinionData.documentId, + // audit_point: opinionData.auditPoint, + // found_issue: opinionData.foundIssue, + proposed_score: opinionData.deductionScore, + reason: opinionData.auditOpinion + }; + + const response = await fetch(`${API_BASE_URL}/admin/cross_review/proposals`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestData) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || '提交失败'); + } + + return { + data: { + success: true, + message: '意见提交成功', + data: data + } + }; + } catch (error) { + console.error('提交交叉评查意见失败:', error); + return { + error: error instanceof Error ? error.message : '提交意见失败', + status: 500 + }; + } +} + +/** + * 获取交叉评查意见列表 + * @param documentId 文档ID + * @returns 意见列表 + */ +export async function getCrossCheckingOpinions(documentId: string | number): Promise> { + try { + const response = await postgrestPost('rpc/get_cross_checking_opinions', { + p_document_id: documentId + }); + + if (response.error) { + return { + error: response.error, + status: response.status || 500 + }; + } + + return { + data: (response.data as CrossCheckingOpinion[]) || [] + }; + } catch (error) { + console.error('获取交叉评查意见失败:', error); + return { + error: error instanceof Error ? error.message : '获取意见列表失败', + status: 500 + }; + } +} diff --git a/app/api/evaluation_points/reviews.ts b/app/api/evaluation_points/reviews.ts index 958bcc9..5dd3656 100644 --- a/app/api/evaluation_points/reviews.ts +++ b/app/api/evaluation_points/reviews.ts @@ -109,6 +109,19 @@ interface ContractStructureComparison { [key: string]: unknown; } +// 定义评分提案数据接口 +interface ScoringProposal { + id: string | number; + evaluation_result_id: string | number; + proposer_id: string | number; + proposed_score: number; + reason: string; + status: string; + created_at: string; + updated_at: string; + document_id: string | number; +} + /** * 获取当前评查文件的所有评查点结果 * @param fileId 评查文件ID @@ -294,6 +307,26 @@ export async function getReviewPoints(fileId: string) { // console.log('groupsMap-------', groupsMap); + + //从scoring_proposals表中获取评分提案数据,用于交叉评查 + const scoringProposalsParams: PostgrestParams = { + select: '*', + filter: { + 'document_id': `eq.${fileId}` + } + }; + const scoringProposalsResponse = await postgrestGet('scoring_proposals', scoringProposalsParams); + + if (scoringProposalsResponse.error) { + return { error: scoringProposalsResponse.error, status: scoringProposalsResponse.status }; + } + const scoringProposalsData = extractApiData(scoringProposalsResponse.data) || []; + + + + + + // 构建前端所需的数据格式 const resultData: ReviewPointResult[] = evaluationResultsData.map(result => { const point = pointsMap.get(result.evaluation_point_id) || {} as EvaluationPoint; @@ -394,6 +427,10 @@ export async function getReviewPoints(fileId: string) { // 评查配置: point.evaluation_config evaluationConfig: point.evaluation_config || {}, + // 评查点evaluation_point中的fail_message和pass_message 用于交叉评查的提出意见 + failMessage: point.fail_message || '', + passMessage: point.pass_message || '', + evaluatedPointResultsLog: evaluatedPointResultsLog || {} // evaluatedPointResultsLog: { // rules:[ @@ -671,8 +708,8 @@ export async function getReviewPoints(fileId: string) { issueCount: issueCount }; // console.log("reviewInfo-------",JSON.stringify(reviewInfo,null,2)); - // data->reviewPoints stats->statistics reviewInfo->reviewInfo document->document - return { data: resultData, stats, reviewInfo, document: documentData.data, comparison_document: comparisonDocument }; + // data->reviewPoints stats->statistics reviewInfo->reviewInfo document->document scoring_proposals->scoringProposalsData + return { data: resultData, stats, reviewInfo, document: documentData.data, comparison_document: comparisonDocument, scoring_proposals: scoringProposalsData }; } /** diff --git a/app/components/cross-checking/FileInfo.tsx b/app/components/cross-checking/FileInfo.tsx new file mode 100644 index 0000000..c53a7c0 --- /dev/null +++ b/app/components/cross-checking/FileInfo.tsx @@ -0,0 +1,91 @@ +/** + * 交叉评查文件信息组件 + */ + +interface FileInfoProps { + fileInfo: { + fileName: string; + contractNumber: string; + fileSize?: string; + fileFormat?: string; + pageCount?: number; + uploadTime?: string; + uploadUser?: string; + auditStatus?: number; + path?: string; + previousRoute?: string; + fileType?: string; + }; + onConfirmResults: () => void; +} + +export function FileInfo({ fileInfo, onConfirmResults }: FileInfoProps) { + + const handleDownloadFile = () => { + if (fileInfo.path) { + // 创建一个隐藏的下载链接 + const link = document.createElement('a'); + link.href = fileInfo.path; + link.download = fileInfo.fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } else { + alert('文件路径不存在,无法下载'); + } + }; + + const handleExportReport = () => { + alert('导出交叉评查报告功能'); + }; + + return ( +
+
+
+ {/* 文件基本信息已在面包屑区域显示,这里可以不重复显示 */} +
+ + {/* 操作按钮区域 */} +
+ {/* 下载原文件按钮 */} + + + {/* 导出评查报告按钮 */} + + + {/* 确认评查结果按钮 - 只在未审核通过时显示 */} + {fileInfo.auditStatus !== 1 && ( + + )} + + {/* 已确认状态显示 */} + {fileInfo.auditStatus === 1 && ( + + + 评查结果已确认 + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/app/components/cross-checking/FilePreview.tsx b/app/components/cross-checking/FilePreview.tsx new file mode 100644 index 0000000..e547157 --- /dev/null +++ b/app/components/cross-checking/FilePreview.tsx @@ -0,0 +1,599 @@ +/** + * 文件预览组件 + * 显示文档内容和评查点高亮 + */ +import { useState, useEffect, useRef, ChangeEvent } from 'react'; +import { Document, Page, pdfjs } from 'react-pdf'; +import { DOCUMENT_URL } from '~/api/axios-client'; + +// 设置worker路径为public目录下的worker文件 +// 使用已经下载的兼容版本 (pdfjs-dist v2.12.313) +pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.js'; + +// 导入统一的ReviewPoint类型 +import { type ReviewPoint } from './'; +import { toastService } from '../ui/Toast'; + +/** + * 自定义样式 + * 这些样式解决了PDF页面在放大时互相重叠的问题 + */ +const styles = { + pdfContainer: { + display: 'flex', + flexDirection: 'column' as const, + alignItems: 'center', + width: '100%', + position: 'relative' as const, + }, + pageContainer: { + display: 'flex', + flexDirection: 'column' as const, + alignItems: 'center', + width: '100%', + position: 'relative' as const, + } +}; + +// 定义文档内容类型 +interface FileContent { + title: string; + contractNumber: string; + path: string; + ocrResult?: { + __meta?: { + page_offset?: number; + }; + }; // 添加ocrResult属性 + parties: { + partyA: { + name: string; + address: string; + representative: string; + phone: string; + }; + partyB: { + name: string; + address: string; + representative: string; + phone: string; + }; + }; + sections: { + title: string; + content: string; + }[]; + template_contract_path?: string; +} + +interface FilePreviewProps { + fileContent: FileContent; + reviewPoints?: ReviewPoint[]; // 设为可选 + activeReviewPointResultId: string | null; + targetPage?: number; // 新增目标页码参数 + isStructuredView?: boolean; // 是否显示结构化视图 +} + +// export function FilePreview({ fileContent, reviewPoints, activeReviewPointResultId, targetPage }: FilePreviewProps) { +export function FilePreview({ fileContent, activeReviewPointResultId, targetPage, isStructuredView = false }: FilePreviewProps) { + const [zoomLevel, setZoomLevel] = useState(100); + // const [highlightsVisible, setHighlightsVisible] = useState(true); + const contentRef = useRef(null); + const [numPages, setNumPages] = useState(null); + const [loadError, setLoadError] = useState(null); + const [pageInputValue, setPageInputValue] = useState(''); + + // 拖拽状态管理 + const [dragMode, setDragMode] = useState(false); // 是否处于拖拽模式 + const [isDragging, setIsDragging] = useState(false); + const [dragCursor, setDragCursor] = useState('default'); + const lastMousePosRef = useRef({ x: 0, y: 0 }); + + // 放大文档 + const handleZoomIn = () => { + if (zoomLevel < 200) { + setZoomLevel(prevZoom => prevZoom + 10); + } + }; + + // 缩小文档 + const handleZoomOut = () => { + if (zoomLevel > 50) { + setZoomLevel(prevZoom => prevZoom - 10); + } + }; + + // 切换拖拽模式 + const toggleDragMode = () => { + setDragMode(prev => !prev); + setDragCursor(prev => prev === 'default' ? 'grab' : 'default'); + setIsDragging(false); + }; + + // 处理拖拽开始 + const handleMouseDown = (e: React.MouseEvent) => { + if (!dragMode || e.button !== 0) return; // 只在拖拽模式下响应左键点击 + + // 防止选中文本 + e.preventDefault(); + + // 设置拖拽状态 + setIsDragging(true); + setDragCursor('grabbing'); + + // 记录鼠标初始位置 + lastMousePosRef.current = { + x: e.clientX, + y: e.clientY + }; + }; + + // 处理拖拽过程 + const handleMouseMove = (e: React.MouseEvent) => { + if (!dragMode || !isDragging || !contentRef.current) return; + + // 计算鼠标移动距离 + const dx = e.clientX - lastMousePosRef.current.x; + const dy = e.clientY - lastMousePosRef.current.y; + + // 更新容器滚动位置 + contentRef.current.scrollLeft -= dx; + contentRef.current.scrollTop -= dy; + + // 更新鼠标位置记录 + lastMousePosRef.current = { + x: e.clientX, + y: e.clientY + }; + }; + + // 处理拖拽结束 + const handleMouseUp = () => { + if (!dragMode) return; + + setIsDragging(false); + setDragCursor('grab'); + }; + + // 监听鼠标离开窗口事件 + useEffect(() => { + const handleMouseLeave = () => { + if (dragMode && isDragging) { + setIsDragging(false); + setDragCursor('grab'); + } + }; + + document.addEventListener('mouseleave', handleMouseLeave); + document.addEventListener('mouseup', handleMouseUp as EventListener); + + return () => { + document.removeEventListener('mouseleave', handleMouseLeave); + document.removeEventListener('mouseup', handleMouseUp as EventListener); + }; + }, [isDragging, dragMode]); + + // 处理页面跳转 + const prevTargetPageRef = useRef(undefined); + useEffect(() => { + // 调试信息:记录组件状态 + // console.log(`FilePreview更新 - isStructuredView:${isStructuredView}, targetPage:${targetPage}, activeReviewPointResultId:${activeReviewPointResultId}, numPages:${numPages}`); + + // 如果有目标页码,并且与上次相同,提示用户 + if(targetPage && numPages && targetPage <= numPages && targetPage === prevTargetPageRef.current){ + // toastService.success(`已跳转至目标页码`); + } + // 如果有目标页码,并且与上次不同或activeReviewPointId变化了,则执行跳转 + if (targetPage && numPages && targetPage <= numPages) { + // if (targetPage && numPages && targetPage <= numPages && (targetPage !== prevTargetPageRef.current || activeReviewPointResultId)) { + prevTargetPageRef.current = targetPage; + let newTargetPage = targetPage; + + // 页码偏移量 + try { + // 安全地访问ocrResult + if (fileContent.ocrResult && fileContent.ocrResult.__meta && fileContent.ocrResult.__meta.page_offset) { + // 可以根据需要使用page_offset调整目标页面 + newTargetPage = targetPage + fileContent.ocrResult.__meta.page_offset; + } + } catch (error) { + console.error("访问ocrResult时出错:", error); + toastService.error("访问ocrResult时出错:" + (error instanceof Error ? error.message : '未知错误')); + } + + const pageElementId = `page-${newTargetPage}${isStructuredView ? '-structured' : ''}`; + // console.log(`尝试跳转到元素ID: ${pageElementId}`); + + const pageElement = document.getElementById(pageElementId); + if (pageElement) { + // console.log(`跳转到第${newTargetPage}页,对应评查点结果ID: ${activeReviewPointResultId}`); + pageElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } else { + console.warn(`未找到页面元素: ${pageElementId}`); + } + } + }, [targetPage, numPages, fileContent, activeReviewPointResultId, isStructuredView]); + + // 获取评查点对应的样式类 + // const getHighlightClass = (status: string) => { + // switch (status) { + // case 'warning': + // return 'warning'; + // case 'error': + // return 'error'; + // case 'success': + // return 'success'; + // default: + // return 'warning'; + // } + // }; + + // 处理页码输入变化 + const handlePageInputChange = (e: ChangeEvent) => { + // 只允许输入数字 + const value = e.target.value.replace(/\D/g, ''); + setPageInputValue(value); + }; + + // 处理页码跳转 + const handlePageJump = () => { + if (!pageInputValue || !numPages) return; + + const targetPageNum = parseInt(pageInputValue, 10); + + // 验证页码是否在有效范围内 + if (targetPageNum > 0 && targetPageNum <= numPages) { + // 找到目标页面元素并滚动到该位置 + const pageElement = document.getElementById(`page-${targetPageNum}`); + if (pageElement) { + pageElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } else { + // 页码超出范围,显示错误信息或重置输入 + toastService.warning(`请输入有效页码 (1-${numPages})`); + setPageInputValue(''); + } + }; + + // 处理回车键跳转 + const handlePageInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handlePageJump(); + } + }; + + // PDF文档加载成功回调函数 + function onDocumentLoadSuccess({ numPages }: { numPages: number }) { + setNumPages(numPages); + // console.log("PDF加载成功,页数:", numPages); + } + + // 计算页面在缩放后的实际间距 + const calculatePageMargin = (zoomFactor: number) => { + // 基础间距为30px,随着缩放倍数线性增加 + const baseMargin = 30; + // 页面缩放后,需要额外添加的间距 = (缩放倍数 - 1) * 页面高度 + const additionalMargin = Math.max(0, (zoomFactor - 1) * 800); // 800是估计的页面高度 + return baseMargin + additionalMargin; + }; + + // 滚动到顶部 + const handleScrollToTop = () => { + if (contentRef.current) { + contentRef.current.scrollTo({ top: 0, behavior: 'smooth' }); + } + }; + + /** + * 渲染PDF文档的所有页面 + * + * 功能描述: + * 1. 生成PDF所有页面的渲染数组,每个页面包含页码标识和实际页面内容 + * 2. 处理页面缩放,通过CSS transform实现页面大小调整 + * 3. 在每个页面上标记对应的评查点高亮区域 + * 4. 处理评查点的激活状态,显示特殊的高亮效果 + * + * @returns {JSX.Element[] | null} 返回所有页面组件的数组,如果没有页数信息则返回null + */ + const renderAllPages = () => { + // 如果还没有获取到PDF总页数,返回null + if (!numPages) return null; + + // 用于存储所有页面组件的数组 + const pages = []; + + // 遍历每一页,生成对应的页面组件 + for (let i = 1; i <= numPages; i++) { + // 计算当前缩放级别下的页面容器样式 + const zoomFactor = zoomLevel / 100; + const pageContainerStyle = { + ...styles.pageContainer, + marginBottom: `${calculatePageMargin(zoomFactor)}px`, // 动态计算页面间距 + }; + + // 为结构化视图和普通视图创建不同的ID + const pageId = isStructuredView ? `page-${i}-structured` : `page-${i}`; + + // 为每一页创建组件 + pages.push( +
+ {/* 页码标识,显示在页面上方 */} +
第 {i} 页
+ + {/* 页面容器,应用缩放变换,设置相对定位用于放置评查点高亮 */} +
+ {/* 渲染PDF页面组件 */} + + + {/* 渲染评查点高亮区域 */} + {/* {highlightsVisible && pageReviewPoints.map(point => { + // 判断当前评查点是否为激活状态(被选中) + const isActive = point.id === activeReviewPointId; + + return ( + // 评查点高亮区域 +
+ ); + })} */} +
+
+ ); + } + + // 返回所有页面组件数组 + return pages; + }; + + // 渲染文档内容 + const renderDocumentContent = () => { + const real_path = fileContent.path || fileContent.template_contract_path || ''; + + // 如果路径无效,显示错误信息 + if (!real_path) { + if(!fileContent.template_contract_path){ + return ( +
+

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

+
+ ); + } + return ( +
+

无法加载文件:路径无效

+
+ ); + } + + // console.log('real_path',real_path); + // 获取文件扩展名 + const fileExtension = real_path.split('.').pop()?.toLowerCase(); + + // PDF内容渲染 + const renderPdfContent = () => ( +
100 ? `${zoomLevel}%` : '100%', + overflow: 'visible' + }} + > + { + console.error("PDF加载错误:", error); + setLoadError("PDF文档加载失败:" + (error.message || "未知错误")); + }} + className="w-full" + error={
PDF文档加载失败,请检查链接或网络连接。
} + noData={
无数据
} + loading={
PDF加载中...
} + > + {renderAllPages()} +
+
+ ); + + // 结构化数据渲染 + const renderStructuredData = () => ( +
+
结构化数据:
+ {fileContent.ocrResult ? ( +
+
+              {JSON.stringify(fileContent.ocrResult, null, 2)}
+            
+
+ ) : ( +
+

无结构化数据可显示

+
+ )} +
+ ); + + // 根据文件类型选择不同的渲染方式 + if (fileExtension === 'pdf') { + // 结构化视图模式:显示PDF和结构化数据 + if (isStructuredView) { + return ( +
+ {renderPdfContent()} + {renderStructuredData()} +
+ ); + } + // 普通模式:仅显示PDF + return renderPdfContent(); + } else { + // 非PDF文件显示不支持消息 + return ( +
+

暂不支持预览此类型的文件:{fileExtension}

+
+ ); + } + }; + + return ( +
+
+
+ + + {isStructuredView ? '模板预览' : '文件预览'} + +
+
+ + + + {/* 页码跳转控件 */} +
+ + + {numPages && ( + + / {numPages} + + )} +
+ + 比例:{zoomLevel}% + + +
+
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/app/components/cross-checking/ReviewPointsList.tsx b/app/components/cross-checking/ReviewPointsList.tsx new file mode 100644 index 0000000..79b1b5b --- /dev/null +++ b/app/components/cross-checking/ReviewPointsList.tsx @@ -0,0 +1,2285 @@ +/** + * 评查点列表组件 + * + * 功能概述: + * - 展示评查结果统计信息(总计、通过、警告、错误数量) + * - 提供评查点过滤功能(按状态和搜索文本) + * - 显示评查点详细信息(标题、状态、内容、建议修改等) + * - 支持评查点操作(一键替换、人工审核等) + * + * 组件结构: + * - 统计区域: 显示评查点数量统计 + * - 搜索区域: 提供文本搜索功能 + * - 评查点列表: 展示所有评查点 + * - 评查点卡片: 展示单个评查点详情 + * - 评查点头部: 显示标题和状态 + * - 评查点内容: 显示当前内容和问题 + * - 建议修改区域: 显示建议的修改内容 + */ +import { useState, useEffect, useRef } from 'react'; +import { toastService } from '../ui/Toast'; +import { createPortal } from 'react-dom'; // 导入React Portal API,用于将组件渲染到DOM树的不同位置 +import { Tooltip } from '../ui/Tooltip'; +import { Modal } from '../ui/Modal'; +import { submitCrossCheckingOpinion, type SubmitOpinionRequest } from '../../api/cross-checking/cross-file-result'; +// import '../../styles/components/TooltipStyles.css'; + +/** + * 比较方法映射 + * 将后端返回的比较方法英文值映射为友好的中文显示 + */ +const compareMethodMap: Record = { + 'exact': '精确匹配', + 'contains': '包含关系', + 'semantic': '大模型语义匹配', + // 可以根据需要添加更多映射 +}; + +/** + * 获取比较方法的中文显示 + * @param method 比较方法的原始值 + * @returns 映射后的中文显示文本 + */ +const getCompareMethodText = (method?: string): string => { + if (!method) return '相等'; + const text = compareMethodMap[method] || method; + // 确保返回的是字符串类型 + return typeof text === 'string' ? text : String(text); +}; + +/** + * 规则类型映射 + * 将后端返回的规则类型英文值映射为友好的中文显示 + */ +const ruleTypeMap: Record = { + 'exists': '有无判断', + 'format': '格式判断', + 'logic': '逻辑判断', + 'regex': '正则表达式', + // 可以根据需要添加更多映射 +}; + +/** + * 获取规则类型的中文显示 + * @param type 规则类型的原始值 + * @returns 映射后的中文显示文本 + */ +const getRuleTypeText = (type?: string): string => { + if (!type) return ''; + return ruleTypeMap[type] || type; +}; + +/** + * 评查点类型定义 + * 用于展示单个评查结果 + */ +export interface ReviewPoint { + id: string; + documentId?: string; + pointId?: string; + editAuditStatusId?: string | number; + editAuditStatus: number; + editAuditStatusMessage?: string; // 添加审核意见字段 + pointName: string; + title: string; + groupName: string; + status: string; + content: Record; + suggestion: string; + needsHumanReview?: boolean; + humanReviewNote?: string; + humanReviewBy?: string; + humanReviewTime?: string; + contentPage?: Record; + position?: { + section: string; + index: number; + }; + result?: boolean; + legalBasis?: { + name?: string; + content?: string; + articles?: Array; + [key: string]: unknown; + }; + postAction?: string; + actionContent?: string; + failMessage?: string; + passMessage?: string; + evaluationConfig?: { + rules?: Array<{ + type: string; + config?: { + fields?: string[]; + pairs?: Array<{ sourceField?: string; targetField?: string }>; + logic?: string; + }; + }>; + }; + evaluatedPointResultsLog?: { + rules: Array<{ + id: string; + type: string; + res?: boolean; + config: Record; + }>; + }; +} + +// 统计数据类型 +interface Statistics { + total: number; + success: number; + warning: number; + error: number; + score: number; +} + +// 定义评分提案数据接口 +interface ScoringProposal { + id: string | number; + evaluation_result_id: string | number; + proposer_id: string | number; + proposed_score: number; + reason: string; + status: string; + created_at: string; + updated_at: string; + document_id: string | number; +} + +interface ReviewPointsListProps { + reviewPoints: ReviewPoint[]; + statistics: Statistics; + activeReviewPointResultId: string | null; + onReviewPointSelect: (id: string, page?: number) => void; + onStatusChange?: (id: string, editAuditStatusId: string | number, status: string, message: string) => void; + scoringProposals?: ScoringProposal[]; +} + +/** + * 全局状态对象,存储当前活动的提示框信息 + * 这种方式避免了复杂的状态提升或Context API的使用 + */ +let activeTooltip = { + show: false, // 控制提示框是否显示 + content: null as React.ReactNode, // 提示框内容(React节点) + position: { top: 0, left: 0 }, // 提示框在屏幕上的位置 + ready: false // 新增:控制是否已准备好显示 +}; + +/** + * 提示框Portal组件 + * + * 使用React Portal将提示框渲染到document.body下, + * 这样可以确保提示框不受任何父元素overflow或z-index限制 + */ +function TooltipPortal() { + // 使用本地状态保存提示框信息的副本 + const [tooltip, setTooltip] = useState(activeTooltip); + + useEffect(() => { + // 通过自定义事件机制监听全局tooltip状态更新 + const updateTooltip = () => { + // 使用扩展运算符创建对象副本,确保状态更新被React检测到 + setTooltip({...activeTooltip}); + }; + + // 添加事件监听器 + window.addEventListener('tooltip-update', updateTooltip); + + // 组件卸载时清理事件监听器 + return () => { + window.removeEventListener('tooltip-update', updateTooltip); + }; + }, []); + + // 如果不显示或没有内容,则不渲染任何东西 + if (!tooltip.show || !tooltip.content) return null; + + // 使用createPortal将提示框内容渲染到document.body + return createPortal( +
+ {tooltip.content} + {/* 添加小三角形指向提示框指向的元素 */} +
+
, + document.body // 将内容挂载到body元素,完全脱离原组件DOM结构 + ); +} + +/** + * 显示提示框的辅助函数 + * @param content 要显示的React节点内容 + * @param position 显示位置坐标 + */ +function showTooltip(content: React.ReactNode, position: { top: number; left: number }): void { + // 先设置内容和位置,但不立即显示 + activeTooltip = { + show: true, + content, + position, + ready: false // 初始设为未准备好 + }; + + // 触发事件,让TooltipPortal渲染tooltip(但不可见) + window.dispatchEvent(new Event('tooltip-update')); + + // 使用RAF确保tooltip已渲染到DOM后再计算最终位置 + requestAnimationFrame(() => { + // 查找刚创建的tooltip元素 + const tooltipElement = document.querySelector('.fixed.bg-white.shadow-lg.rounded-md') as HTMLElement; + + if (tooltipElement) { + // 获取tooltip的实际尺寸 + const tooltipRect = tooltipElement.getBoundingClientRect(); + + // 重新计算位置,确保tooltip不会超出视口 + let adjustedTop = position.top; + let adjustedLeft = position.left; + + // 检查是否超出右边界 + if (adjustedLeft - tooltipRect.width < 0) { + adjustedLeft = tooltipRect.width + 10; // 留一些边距 + } + + // 检查是否超出上边界 + if (adjustedTop - tooltipRect.height / 2 < 0) { + adjustedTop = tooltipRect.height / 2 + 10; + } + + // 检查是否超出下边界 + if (adjustedTop + tooltipRect.height / 2 > window.innerHeight) { + adjustedTop = window.innerHeight - tooltipRect.height / 2 - 10; + } + + // 更新位置并设为准备好显示 + activeTooltip.position = { top: adjustedTop, left: adjustedLeft }; + activeTooltip.ready = true; + + // 再次触发事件更新显示状态 + window.dispatchEvent(new Event('tooltip-update')); + } else { + // 如果找不到tooltip元素,直接显示 + activeTooltip.ready = true; + window.dispatchEvent(new Event('tooltip-update')); + } + }); +} + +/** + * 隐藏提示框的辅助函数 + */ +function hideTooltip(): void { + // 设置为不显示状态并重置ready状态 + activeTooltip.show = false; + activeTooltip.ready = false; + // 触发自定义事件,通知TooltipPortal组件更新状态 + window.dispatchEvent(new Event('tooltip-update')); +} + + +/** + * React组件表格Tooltip + * 将文本数据解析为表格并使用React组件渲染 + * 条件性Tooltip组件 + * 只有当内容超过2行时才显示tooltip + */ +const ReactTableTooltip = ({ content }: { content: string }) => { + const [showTooltip, setShowTooltip] = useState(false); + const [renderedContent, setRenderedContent] = useState(null); + const textRef = useRef(null); + + const isTableLike = content.includes('\t') && content.includes('\n'); + + useEffect(() => { + const checkTextOverflow = () => { + const element = textRef.current; + if (element) { + // 如果是表格格式,总是显示tooltip;否则只在文本溢出时显示 + setShowTooltip(isTableLike || element.scrollHeight > element.clientHeight); + } + }; + + // 预渲染内容并缓存 + if (isTableLike) { + setRenderedContent(renderReactTable(content)); + } else { + setRenderedContent(content); + } + + requestAnimationFrame(checkTextOverflow); + window.addEventListener('resize', checkTextOverflow); + return () => { + window.removeEventListener('resize', checkTextOverflow); + }; + }, [content, isTableLike]); + + // 解析表格数据 + const parseTableData = (text: string) => { + const rows = text.split('\n').map(row => row.split('\t')); + return rows; + }; + + // 渲染React表格 + const renderReactTable = (text: string) => { + try { + const tableData = parseTableData(text); + const hasHeader = tableData.length > 0; + + return ( +
+ + {hasHeader && ( + + + {tableData[0].map((cell, cellIndex) => ( + + ))} + + + )} + + {tableData.slice(1).map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
+ {cell || ' '} +
+ {cell || ' '} +
+
+ ); + } catch (error) { + console.error('表格渲染错误:', error); + return
表格渲染错误
; + } + }; + + + + return ( +
+ {showTooltip ? ( + +
+ {content} +
+
+ ) : ( +
+ {content} +
+ )} +
+ ); +}; + +export function ReviewPointsList({ + reviewPoints, + statistics, + activeReviewPointResultId, + onReviewPointSelect, + scoringProposals = [] +}: ReviewPointsListProps) { + // 状态管理 + const [searchText, setSearchText] = useState(''); // 搜索文本 + const [statusFilter, setStatusFilter] = useState(null); // 状态过滤 + + // 在组件中使用scoringProposals(这里只是简单使用以避免linter警告) + // 将来可以用于显示相关的评分提案信息 + useEffect(() => { + if (scoringProposals && scoringProposals.length > 0) { + console.log('收到评分提案数据:', scoringProposals.length, '个提案'); + } + }, [scoringProposals]); + + // 提出意见模态框相关状态 + const [isOpinionModalOpen, setIsOpinionModalOpen] = useState(false); + const [selectedReviewPoint, setSelectedReviewPoint] = useState(null); + const [opinionForm, setOpinionForm] = useState({ + // 评查点名称 + auditPoint: '', + // 发现问题 + foundIssue: '', + // 审查意见 + auditOpinion: '', + // 扣分 + deductionScore: 0 + }); + const [isSubmittingOpinion, setIsSubmittingOpinion] = useState(false); + + // 存放评查点ID与有效页码的映射 + const [effectivePages, setEffectivePages] = useState>({}); + + /** + * 打开提出意见模态框 + */ + const handleOpenOpinionModal = (reviewPoint: ReviewPoint) => { + setSelectedReviewPoint(reviewPoint); + setOpinionForm({ + auditPoint: reviewPoint.pointName, + foundIssue: reviewPoint.result ? (reviewPoint.passMessage || '') : (reviewPoint.failMessage || ''), + auditOpinion: '', + deductionScore: 0 + }); + setIsOpinionModalOpen(true); + }; + + /** + * 关闭提出意见模态框 + */ + const handleCloseOpinionModal = () => { + setIsOpinionModalOpen(false); + setSelectedReviewPoint(null); + setOpinionForm({ + auditPoint: '', + foundIssue: '', + auditOpinion: '', + deductionScore: 0 + }); + }; + + /** + * 处理意见表单输入 + */ + const handleOpinionFormChange = (field: string, value: string | number) => { + setOpinionForm(prev => ({ + ...prev, + [field]: value + })); + }; + + /** + * 提交意见 + */ + const handleSubmitOpinion = async () => { + // 校验表单 + if (!opinionForm.auditOpinion.trim()) { + toastService.error('请填写审查意见'); + return; + } + + if (opinionForm.deductionScore <= 0) { + toastService.error('扣分必须大于0'); + return; + } + + if (opinionForm.deductionScore > 100) { + toastService.error('扣分不能大于100分'); + return; + } + + if (!selectedReviewPoint) { + toastService.error('未选择评查点'); + return; + } + + setIsSubmittingOpinion(true); + + try { + const opinionData: SubmitOpinionRequest = { + reviewPointResultId: selectedReviewPoint.id, + documentId: selectedReviewPoint.documentId || '', + auditPoint: opinionForm.auditPoint, + foundIssue: opinionForm.foundIssue, + auditOpinion: opinionForm.auditOpinion, + deductionScore: opinionForm.deductionScore + }; + + const response = await submitCrossCheckingOpinion(opinionData); + + if (response.error) { + toastService.error(response.error); + return; + } + + toastService.success('意见提交成功'); + handleCloseOpinionModal(); + } catch (error) { + console.error('提交意见失败:', error); + toastService.error('提交意见失败,请稍后重试'); + } finally { + setIsSubmittingOpinion(false); + } + }; + + /** + * 过滤评查点 + * 根据搜索文本和状态过滤条件筛选评查点 + */ + const filteredReviewPoints = reviewPoints.filter(point => { + // 匹配搜索文本 + const matchesSearch = searchText === '' || + point.pointName.toLowerCase().includes(searchText.toLowerCase()) || + point.title.toLowerCase().includes(searchText.toLowerCase()) || + // point.groupName.toLowerCase().includes(searchText.toLowerCase()) || + JSON.stringify(point.content).toLowerCase().includes(searchText.toLowerCase()) + + // 处理状态过滤 + let matchesStatus = false; + + if (statusFilter === null) { + // 未选择过滤条件时显示所有 + matchesStatus = true; + } else if (statusFilter === 'success') { + // 过滤"通过"状态 + matchesStatus = point.result === true; + } else if (statusFilter === 'warning') { + // 过滤"警告"状态 + matchesStatus = point.result === false && (point.status === 'warning' || point.status === 'info'); + } else if (statusFilter === 'error') { + // 过滤"错误"状态 + matchesStatus = point.result === false && point.status === 'error'; + } + // console.log('筛选point', point); + + return matchesSearch && matchesStatus; + }); + // console.log('筛选filteredReviewPoints', filteredReviewPoints); + + + /** + * 渲染评查统计信息 + * 显示总计、通过、警告、错误数量 + */ + const renderStatistics = () => { + // 确保传入的statistics存在,否则使用计算值 + const statsToUse = statistics || { + total: reviewPoints.length, + success: 0, + warning: 0, + error: 0, + score: 0 + }; + + // 计算各个状态的评查点数量 + const successCount = reviewPoints.filter( + point => point.result === true || (point.result === undefined && point.status === 'success') + ).length; + + const warningCount = reviewPoints.filter( + point => point.result === false && (point.status === 'warning' || point.status === 'info') + ).length; + + const errorCount = reviewPoints.filter( + point => point.result === false && point.status === 'error' + ).length; + + // 如果没有计算值,则使用传入的统计值 + const totalToShow = statsToUse.total === 0 ? reviewPoints.length : statsToUse.total; + const successToShow = successCount || statsToUse.success; + const warningToShow = warningCount || statsToUse.warning; + const errorToShow = errorCount || statsToUse.error; + + return ( +
+
+ {/* 总计数量 */} +
+ +
+
+ {/* 通过数量 */} +
+ +
+
+ {/* 警告数量 */} +
+ +
+
+ {/* 错误数量 */} +
+ +
+
+
+ ); + }; + + /** + * 渲染搜索框 + * 用于按文本搜索评查点 + */ + const renderSearchBar = () => { + return ( +
+
+ setSearchText(e.target.value)} + /> + + {searchText && ( + + )} +
+
+ ); + }; + + /** + * 渲染评查点状态标签 + * @param status 状态文本 + * @param result 评查结果 + * @param title 标签提示内容 + * @returns 状态标签组件 + */ + const renderStatusBadge = (status: string, result?: boolean, title?: string) => { + // 优先根据result判断是否通过 + if (result === true) { + return ( + + {title &&
{title}
} +
+ } + placement="top" + theme="light" + trigger="hover" + showArrow={true} + className="tooltip-custom-offset tooltip-top" + fixedPlacement={true} + > + + 通过 + + + ); + } + + // 当result为false时,根据status决定显示警告还是错误 + if (result === false) { + if (status === 'warning' || status === 'info') { + return ( + + {title &&
{title}
} + + } + placement="top" + theme="light" + trigger="hover" + showArrow={true} + className="tooltip-custom-offset tooltip-top" + fixedPlacement={true} + > + + 警告 + +
+ ); + } else if (status === 'error') { + return ( + + {title &&
{title}
} + + } + placement="top" + theme="light" + trigger="hover" + showArrow={true} + className="tooltip-custom-offset tooltip-top" + fixedPlacement={true} + > + + 不通过 + +
+ ); + } + } + }; + + /** + * 渲染评查点主要内容 + * @param reviewPoint 评查点 + * @returns 评查点主要内容组件 + */ + const renderContent = (reviewPoint: ReviewPoint, otherRules: Array>) => { + return ( + <> + {/* 渲染其他规则分组 */} + {otherRules.map((rule, index) => { + return
{renderOtherRule(rule, reviewPoint)}
; + })} + + {/*
*/} + {/* 渲染各个一致性的规则分组 */} + {reviewPoint.evaluatedPointResultsLog?.rules?.map((rule, index) => { + // console.log('rule-------', rule); + if (rule.type === 'consistency') { + // if (rule.res === true && reviewPoint.result === true) { + return
+ {otherRules.length > 0 &&
} + {renderConsistencyRule(rule, reviewPoint)} +
; + // }else { + // return null; + // } + } + + if (rule.type === 'ai') { + return
+ {otherRules.length > 0 &&
} + {renderModelRule(rule, reviewPoint)} +
; + } + + })} + + + ); + }; + + + /** + * 渲染评查点一致性的规则的样式 + * @param singleReviewPoint 一个评查点的一致性规则对象 + * @param reviewPoint 评查点 + * @returns 评查点一致性的规则的样式 + */ + const renderConsistencyRule = (singleReviewPoint: Record,reviewPoint: ReviewPoint) => { + // 如果评查点结果为false,则判断单个规则是否通过,如果一致,则渲染 + if (reviewPoint.result !== singleReviewPoint.res) { + return null; + } + + if (!singleReviewPoint || Object.keys(singleReviewPoint).length === 0) { + return null; + } + + // console.log('singleReviewPoint-------', singleReviewPoint); + // 检查是否存在配置和pairs数组 + const config = singleReviewPoint.config as { + logic?: string; + pairs?: Array<{ + sourceField: Record; + targetField: Record; + res: boolean; + compareMethod?: string; + }>; + selectedFields?: string[] + } | undefined; + + if (!config || !config.pairs || !Array.isArray(config.pairs) || config.pairs.length === 0) { + return null; + } + + // 处理配对数据 + const pairs = config.pairs; + + // 获取第一个有效页码 + if (reviewPoint.id && !effectivePages[reviewPoint.id]) { + for (const pair of pairs) { + // 检查sourceField中是否有有效页码 + const sourceFieldKey = Object.keys(pair.sourceField)[0]; + if (sourceFieldKey && pair.sourceField[sourceFieldKey].page && + Number(pair.sourceField[sourceFieldKey].page) > 0) { + // 保存页码 + setEffectivePages(prev => ({ + ...prev, + [reviewPoint.id || '']: Number(pair.sourceField[sourceFieldKey].page) + })); + break; + } + + // 如果sourceField没有有效页码,检查targetField + const targetFieldKey = Object.keys(pair.targetField)[0]; + if (targetFieldKey && pair.targetField[targetFieldKey].page && + Number(pair.targetField[targetFieldKey].page) > 0) { + // 保存页码 + setEffectivePages(prev => ({ + ...prev, + [reviewPoint.id || '']: Number(pair.targetField[targetFieldKey].page) + })); + break; + } + } + } + + // 查找链条关系 + const findChains = () => { + type ChainItem = { + field: string; + data: { + key: string; + page: number; + value: string + }; + res: boolean; + compareMethod?: string; + }; + + const chains: Array> = []; + const visited = new Set(); + + // 构建字段映射关系 + const fieldMap = new Map>(); + + pairs.forEach(pair => { + // 提取源字段和目标字段的名称 + const sourceFieldKey = Object.keys(pair.sourceField)[0]; + const targetFieldKey = Object.keys(pair.targetField)[0]; + + if (!fieldMap.has(sourceFieldKey)) { + fieldMap.set(sourceFieldKey, []); + } + + fieldMap.get(sourceFieldKey)?.push({ + targetField: targetFieldKey, + data: { + source: { key: sourceFieldKey, ...pair.sourceField[sourceFieldKey] }, + target: { key: targetFieldKey, ...pair.targetField[targetFieldKey] } + }, + res: pair.res, + compareMethod: pair.compareMethod + }); + }); + // console.log('fieldMap-------', fieldMap); + + // 查找链条的起始点(只作为源不作为目标的字段) + const startPoints = new Set(); + for (const [key] of fieldMap.entries()) { + let isTarget = false; + for (const pair of pairs) { + const targetFieldKey = Object.keys(pair.targetField)[0]; + if (targetFieldKey === key) { + isTarget = true; + break; + } + } + if (!isTarget) { + startPoints.add(key); + } + } + // console.log('startPoints-------', startPoints); + + // 从每个起始点开始构建链条 + for (const startPoint of startPoints) { + if (visited.has(startPoint)) continue; + + const tempChain: Array = []; + let currentField = startPoint; + + // 向后构建链条 + while (fieldMap.has(currentField)) { + const targets = fieldMap.get(currentField); + if (!targets || targets.length === 0) break; + + // 找到第一个未访问的目标 + let nextTarget = null; + for (const target of targets) { + if (!visited.has(target.targetField)) { + nextTarget = target; + break; + } + } + + if (!nextTarget) break; + + // 添加源字段到链条 + if (tempChain.length === 0) { + tempChain.push({ + field: currentField, + data: nextTarget.data.source, + res: nextTarget.res, + compareMethod: nextTarget.compareMethod + }); + } + + // 添加目标字段到链条 + tempChain.push({ + field: nextTarget.targetField, + data: nextTarget.data.target, + res: nextTarget.res, + compareMethod: nextTarget.compareMethod + }); + + // 标记为已访问 + visited.add(currentField); + visited.add(nextTarget.targetField); + + // 移动到下一个字段 + currentField = nextTarget.targetField; + } + + // console.log('tempChain-------', tempChain); + // 检查是否有链条,并处理链条断点 + if (tempChain.length > 0) { + // 如果链条长度大于1 + if (tempChain.length > 1) { + // 存储所有拆分后的链条 + const splittedChains: Array> = []; + + // 从后往前遍历,检查每个相邻元素之间的连接 + let endIndex = tempChain.length - 1; + + // 从倒数第一个元素开始往前遍历 + for (let i = tempChain.length - 1; i > 0; i--) { + // 检查当前元素与前一个元素的连接是否为false + // 当前元素为tempChain[i],前一个元素为tempChain[i-1] + // 连接结果存储在当前元素(tempChain[i])的result中 + const connectionResult = tempChain[i].res; + + // 如果连接为false,或已到达起始位置,拆分链条 + if (!connectionResult) { + // 从当前断点到结束索引构建一个新链条 + const newChain = tempChain.slice(i, endIndex + 1); + if (newChain.length > 1) { + splittedChains.push(newChain); + + // 将当前断点的前一个元素和后一个元素组成一个新链条 + const newChain_before = tempChain.slice(i-1, i+1); + // console.log('newChain_before-------', newChain_before); + splittedChains.push(newChain_before); + } + + // 更新结束索引为当前位置的前一个 + endIndex = i - 1; + } + + // 当到达第一个元素前一个位置时,需要处理剩余的链条 + if (i === 1) { + // 处理剩余部分 (0 到 endIndex) + const remainingChain = tempChain.slice(0, endIndex + 1); + if (remainingChain.length > 1) { + splittedChains.push(remainingChain); + } + } + } + + // 如果没有任何断点,添加整个链条 + if (splittedChains.length === 0) { + splittedChains.push([...tempChain]); + } + + // 将拆分的链条添加到结果中 + splittedChains.reverse().forEach(chain => { + chains.push(chain); + }); + } else { + // 如果链条长度为1,直接添加 + chains.push([...tempChain]); + } + } + } + // console.log('chains-------', chains); + + // 处理没有找到的孤立对(这种情况只要规则配置是没问题的,就一定不会存在孤立的情况) + for (const pair of pairs) { + const sourceFieldKey = Object.keys(pair.sourceField)[0]; + const targetFieldKey = Object.keys(pair.targetField)[0]; + + if (!visited.has(sourceFieldKey) || !visited.has(targetFieldKey)) { + const isolatedPair: Array = [ + { + field: sourceFieldKey, + data: { key: sourceFieldKey, ...pair.sourceField[sourceFieldKey] }, + res: pair.res + }, + { + field: targetFieldKey, + data: { key: targetFieldKey, ...pair.targetField[targetFieldKey] }, + res: pair.res + } + ]; + + chains.push(isolatedPair); + visited.add(sourceFieldKey); + visited.add(targetFieldKey); + } + } + + return chains; + }; + + const chains = findChains(); + + return ( +
+
+ {chains.map((chain, chainIndex) => { + const isLongChain = chain.length > 2; + const res = chain[1].res; + // 获取compareMethod + // const compareMethod = chain[1].compareMethod || ''; + // 转换为友好的显示文本 + // const compareMethodText = getCompareMethodText(compareMethod); + + // 确定样式类名 + const itemClassName = res + ? "comparison-item match" + : "comparison-item mismatch"; + + // console.log('currentchain-------', chain); + // 如果是长链(3个或以上元素) + if (isLongChain) { + // console.log('currentlongchain-------', chain); + return ( +
{ + e.stopPropagation(); + // 遍历chain找到第一个有效的page + let hasPage = false; + for (const item of chain) { + if (item.data.page && typeof onReviewPointSelect === 'function') { + hasPage = true; + onReviewPointSelect(reviewPoint.id, Number(item.data.page)); + break; + } + } + if (!hasPage) { + // toastService.error('没有找到有效的页码'); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + // 遍历chain找到第一个有效的page + for (const item of chain) { + if (item.data.page && typeof onReviewPointSelect === 'function') { + onReviewPointSelect(reviewPoint.id, Number(item.data.page)); + break; + } + } + } + }} + role="button" + tabIndex={0} + > +
+
+ {/* 展示链条 */} +
+ {chain.map((item, idx) => ( + + {item.field} + {idx < chain.length - 1 && ( + + {typeof chain[idx+1].compareMethod === 'object' + ? '' + : getCompareMethodText(chain[idx+1].compareMethod)} + + + )} + + ))} +
+ {/* 展示链条的每个元素的内容 */} +
+ {chain.map((item, idx) => ( + + ))} +
+
+
+ {res ? ( + + ) : ( + + )} + {/* 使用鼠标事件处理悬停提示 */} +
{ + // 获取元素位置信息 + const rect = e.currentTarget.getBoundingClientRect(); + // 创建提示框内容 + const content = ( +
+ {chain.map((item, idx) => + idx >= 1 ? ( +
+
+ {typeof item.compareMethod === 'object' + ? '' + : `${getCompareMethodText(item.compareMethod)}:`} +
+
+ {res ? '通过' : '不通过'} +
+
+ ) : null + )} +
+ ); + // 显示提示框 + showTooltip(content, { top: rect.top + rect.height/2, left: rect.left }); + }} + onMouseLeave={hideTooltip} + /> +
+
+
+ ); + } + + // 如果是标准的成对比较(2个元素) + return ( +
+
+ {chain[0].field.split('-').pop()} +
+
+ + +
+
+ {res ? ( + + ) : ( + + )} + {/* 使用鼠标事件处理悬停提示 */} +
{ + // 获取元素位置信息 + const rect = e.currentTarget.getBoundingClientRect(); + // 创建提示框内容 + const content = ( +
+
+
+ {typeof chain[1].compareMethod === 'object' + ? '' + : `${getCompareMethodText(chain[1].compareMethod)}:`} +
+
+ {res ? '通过' : '不通过'} +
+
+
+ ); + // 显示提示框,稍微向下偏移,便于鼠标移动到tooltip上 + showTooltip(content, { + top: rect.top + rect.height/2, + left: rect.left + }); + }} + onMouseLeave={hideTooltip} + /> +
+
+ ); + })} +
+
+ ); + }; + + + /** + * 渲染评查点有无判断,格式判断,逻辑判断,正则表达式的规则的样式 + * @param otherRule 评查点规则数据 + * @param reviewPoint 关联的评查点 + * @returns 评查点有无判断,格式判断,逻辑判断,正则表达式的规则的样式 + */ + const renderOtherRule = (otherRule: Record, reviewPoint: ReviewPoint) => { + const fieldKey = otherRule.fieldKey as string; + const fieldValue = otherRule.fieldValue as { + type: Record; + }; + + // 获取res的综合结果 + // 如果存在res=false,则整体结果为false,否则为true + const hasFailure = Object.values(fieldValue?.type || {}).some(item => item.res === false); + const overallResult = !hasFailure; + + // 找到res为false的条目,用于主要显示 + const failedTypeEntry = Object.entries(fieldValue?.type || {}).find(([, item]) => item.res === false); + + // 如果没有失败的条目,则使用第一个条目 + const mainTypeEntry = failedTypeEntry || Object.entries(fieldValue?.type || {})[0]; + + // 如果没有任何条目,则返回空 + if (!mainTypeEntry) return null; + + const [, mainTypeValue] = mainTypeEntry; + + /** + * 创建提示框内容 + * 这个函数返回一个React节点,用于在提示框中显示 + * 它将为每种规则类型(exists/format/logic/regex)创建一个带有状态标识的项目 + */ + const createTooltipContent = () => { + return ( +
+ {Object.entries(fieldValue?.type || {}).map(([typeKey, typeValue]) => ( +
+
{getRuleTypeText(typeKey)}:
+
+ {typeValue.res ? '通过' : '不通过'} +
+
+ ))} +
+ ); + }; + + /** + * 处理鼠标悬停事件 + * 当鼠标悬停在状态指示器上时,计算提示框应该显示的位置并显示提示框 + * @param e 鼠标事件对象 + */ + const handleMouseEnter = (e: React.MouseEvent): void => { + // 获取触发元素的位置信息 + const rect = e.currentTarget.getBoundingClientRect(); + // 调用全局函数显示提示框,传递内容和位置信息 + showTooltip( + createTooltipContent(), + { top: rect.top + rect.height/2, left: rect.left } + ); + }; + + return ( + + ); + }; + + + + /** + * 渲染评查点大模型判断的规则的样式 + * + * 该函数处理AI模型评估的结果展示,包括: + * 1. 从规则配置中提取字段和评估结果 + * 2. 为每个字段创建可点击的UI元素,显示内容和评估状态 + * 3. 展示模型的评估消息 + * 4. 处理字段点击导航到相应页面的逻辑 + * + * @param aiRule 评查点大模型判断的规则对象 + * @param reviewPoint 关联的评查点对象 + * @returns React组件,用于显示AI模型评估结果 + */ + const renderModelRule = (aiRule: Record, reviewPoint: ReviewPoint) => { + + // 从aiRule中提取配置信息 + const config = aiRule.config as { + model?: string; + fields?: Record; + message?: string; + res?: boolean; + } | undefined; + + // 如果评查点评查结果和规则的结果不一致,则不渲染,跳过 + if(config?.res !== reviewPoint.result){ + return null; + } + + // 如果配置不存在,不渲染任何内容 + if (!config) return null; + + // 获取第一个有效页码 + if (reviewPoint.id && !effectivePages[reviewPoint.id] && config.fields) { + for (const field of Object.values(config.fields || {})) { + if (field.page && Number(field.page) > 0) { + setEffectivePages(prev => ({ + ...prev, + [reviewPoint.id || '']: Number(field.page) + })); + break; + } + } + } + + // 创建一个数组来存储需要渲染的JSX元素 + const fieldElements: JSX.Element[] = []; + + // 遍历fields,获取每个字段的值并生成对应的JSX元素 + if (config.fields) { + Object.entries(config.fields).forEach(([key, value], index) => { + const res = value.value.trim() !== ''; + fieldElements.push( + + ); + }); + } + + // 渲染AI模型返回的评估消息 + if (config.message) { + // 检查message是否为对象,如果是则转换为字符串 + const messageContent = typeof config.message === 'object' + ? JSON.stringify(config.message) + : String(config.message); + + // 添加模型评估消息区域,使用蓝色背景突出显示 + fieldElements.push( +
+
+ +

{messageContent}

+
+
+ ); + } + + // 返回包含所有元素的React片段 + return <>{fieldElements}; + }; + + + + /** + * 过滤评查点中的规则,把type是exists、format、logic、regex的规则中重复的进行去重和合并 + * + * 该函数的主要作用: + * 1. 从评查点的evaluatedPointResultsLog中提取特定类型的规则 + * 2. 将相同字段(fieldKey)的不同规则类型结果合并到一起 + * 3. 为UI渲染准备统一结构的数据 + * + * 支持的规则类型: + * - exists: 有无判断规则 + * - format: 格式判断规则 + * - logic: 逻辑判断规则 + * - regex: 正则表达式规则 + * + * @param reviewPoint 评查点对象 + * @returns 合并后的规则数组,每个元素包含字段名和各类规则的评估结果 + */ + const filterOtherRule = (reviewPoint: ReviewPoint) => { + // 定义接口描述规则字段值的结构 + interface RuleFieldValue { + page?: number | string; + value?: string; + type: Record; + } + + const allRule: Array<{ + fieldKey: string; + fieldValue: RuleFieldValue; + }> = []; + + for (const rule of reviewPoint.evaluatedPointResultsLog?.rules || []) { + // 如果评查点评查结果和规则的结果不一致,则不渲染,跳过 + if(rule.config.res !== reviewPoint.result){ + continue; + } + // 处理"有无判断"类型的规则 + if (rule.type === 'exists') { + // 使用类型断言获取config对象的具体结构 + const config = rule.config as { + res: boolean; + fields: Record; + logic?: string; + }; + + // 如果res为true,则遍历fields,提取不为空的字段 + if (config.res) { + // 遍历fields对象的每个属性 + Object.entries(config.fields).forEach(([key, fieldValue]) => { + // 只处理值不为空的字段 + if (fieldValue.value && fieldValue.value.trim() !== '') { + // 创建新对象并添加type标记 + const newItem = { + fieldKey: key, + fieldValue: { + ...fieldValue, + type: { exists: true } + } + }; + + allRule.push(newItem); + } + }); + } else { + // 如果res为false,则遍历fields,提取所有字段 + Object.entries(config.fields).forEach(([key, fieldValue]) => { + // 根据值是否为空添加不同的type标记 + const isValueEmpty = !fieldValue.value || fieldValue.value.trim() === ''; + + // 创建新对象并添加type标记 + const newItem = { + fieldKey: key, + fieldValue: { + ...fieldValue, + type: { exists: isValueEmpty ? false : true } + } + }; + + allRule.push(newItem); + }); + } + } + + // 处理"格式判断"类型的规则 + if (rule.type === 'format') { + // 使用类型断言获取config对象的具体结构 + const config = rule.config as { + res: boolean; + field: Record; + formatType?: string; + parameters?: string; + }; + + // 从config中获取field对象 + // 注意:根据示例,format类型中是field而不是fields + if (config.field) { + // 获取field中唯一的键值对 + const entries = Object.entries(config.field); + if (entries.length > 0) { + const [key, fieldValue] = entries[0]; + + // 创建新对象并添加type标记 + const newItem = { + fieldKey: key, + fieldValue: { + ...fieldValue, + type: { format: config.res } // 标记为format类型,结果为config.res + } + }; + + allRule.push(newItem); + } + } + } + + // 处理"逻辑判断"类型的规则 + if (rule.type === 'logic') { + // 使用类型断言获取config对象的具体结构 + const config = rule.config as { + logic: string; + res: boolean; + conditions: Array<{ + field: Record; + value: string; + operator: string; + res: boolean; + }>; + }; + + // 遍历conditions数组 + if (config.conditions && Array.isArray(config.conditions)) { + config.conditions.forEach(condition => { + // 从condition中获取field对象 + const entries = Object.entries(condition.field); + if (entries.length > 0) { + const [key, fieldValue] = entries[0]; + + // 创建新对象并添加type标记 + const newItem = { + fieldKey: key, + fieldValue: { + ...fieldValue, + type: { logic: condition.res } + } + }; + + allRule.push(newItem); + } + }); + } + } + + // 处理"正则表达式"类型的规则 + if (rule.type === 'regex') { + // 使用类型断言获取config对象的具体结构 + const config = rule.config as { + res: boolean; + field: Record; + pattern?: string; + matchType?: string; + selectedFields?: string[]; + }; + if (config.field) { + const entries = Object.entries(config.field); + if (entries.length > 0) { + const [key, fieldValue] = entries[0]; + + // 创建新对象并添加type标记 + const newItem = { + fieldKey: key, + fieldValue: { + ...fieldValue, + type: { regex: config.res } + } + }; + + allRule.push(newItem); + } + } + } + } + // console.log('allRule-------', allRule); + + + // 对allRule进行去重和合并 + const mergedRules: Array<{ + fieldKey: string; + fieldValue: { + type: Record; + }; + }> = []; + + // 使用对象存储相同fieldKey的项,便于快速查找和合并 + const fieldKeyMap: Record; + }; + }> = {}; + + // 第一步:按fieldKey分组并合并不同类型的规则结果 + allRule.forEach(item => { + const fieldKey = item.fieldKey; + const fieldValue = item.fieldValue; + const typeKey = Object.keys(fieldValue.type)[0]; // 获取类型名称(exists/logic/regex/format) + const typeValue = fieldValue.type[typeKey]; // 获取类型值(true/false) + + // 提取页码和值 + const page = fieldValue.page; + const value = fieldValue.value; + + // 如果是第一次遇到这个fieldKey,创建新条目 + if (!fieldKeyMap[fieldKey]) { + // 创建新的结构 + fieldKeyMap[fieldKey] = { + fieldKey, + fieldValue: { + type: {} + } + }; + } + + // 将类型信息添加到type对象中,允许一个字段有多种规则类型的结果 + fieldKeyMap[fieldKey].fieldValue.type[typeKey] = { + res: typeValue, + page, + value + }; + }); + + // 将合并后的对象转换为数组 + for (const key in fieldKeyMap) { + mergedRules.push(fieldKeyMap[key]); + } + + // 获取第一个有效页码 + if (reviewPoint.id && !effectivePages[reviewPoint.id]) { + // 遍历合并后的规则数组,查找第一个有效页码 + for (const rule of mergedRules) { + // 遍历字段类型对象 + const typeEntries = Object.entries(rule.fieldValue.type); + + // 遍历每种类型规则 + for (const [, typeValue] of typeEntries) { + // 检查是否有有效页码 + if (typeValue.page && Number(typeValue.page) > 0) { + // 找到有效页码,设置状态并跳出循环 + setEffectivePages(prev => ({ + ...prev, + [reviewPoint.id || '']: Number(typeValue.page) + })); + // 使用break跳出当前循环 + break; + } + } + + // 如果已经找到有效页码,跳出外层循环 + if (reviewPoint.id && effectivePages[reviewPoint.id]) { + break; + } + } + } + + // 返回合并后的规则数组 + return mergedRules; + }; + + + + /** + * 渲染评查点内容与建议 + * @param reviewPoint 评查点 + * @returns 评查点内容与建议组件 + */ + const renderReviewPointContent = (reviewPoint: ReviewPoint) => { + + const mergedRules = filterOtherRule(reviewPoint); + // console.log('mergedRules1-------', mergedRules); + + // 根据result和status决定渲染哪种样式 + if (reviewPoint.result === true) { + // 已通过的评查点只显示基本信息和人工审核注释 + return ( + <> + {checkContentPage(reviewPoint).pageIndex === 0 && ( +

该评查点无法找到索引内容,无法自动定位到对应页面。

+ )} + {/* 评查点内容显示区域 */} + {reviewPoint.content && Object.entries(reviewPoint.content).length > 0 && ( +
+ {/* 修改评查结果的结构之后,显示新的结构 */} + {renderContent(reviewPoint, mergedRules)} +
+ )} + + ); + } + + return ( +
+ + {/* 没有索引内容提示 */} + {checkContentPage(reviewPoint).pageIndex === 0 && ( +

该评查点无法找到索引内容,无法自动定位到对应页面。

+ )} + + {/* 建议内容显示区域 */} + {reviewPoint.suggestion && ( +
+
+ +

{reviewPoint.suggestion}

+
+
+ )} + + + {/* 法律依据内容 */} + {reviewPoint.legalBasis && (typeof reviewPoint.legalBasis === 'object') && ( + (reviewPoint.legalBasis.name || reviewPoint.legalBasis.content || + (reviewPoint.legalBasis.articles && Array.isArray(reviewPoint.legalBasis.articles) && reviewPoint.legalBasis.articles.length > 0)) && ( +
+
+ 法律依据 +
+ {reviewPoint.legalBasis.name && ( +

{reviewPoint.legalBasis.name}

+ )} + {reviewPoint.legalBasis.content && ( +

条款内容:{reviewPoint.legalBasis.content}

+ )} + {reviewPoint.legalBasis.articles && Array.isArray(reviewPoint.legalBasis.articles) && reviewPoint.legalBasis.articles.length > 0 && ( +
+

相关条款:

+
    + {reviewPoint.legalBasis.articles.map((item, index) => ( +
  • + {typeof item === 'string' ? item : + typeof item === 'object' && item !== null ? + (item.name ? `${item.name}: ${item.content || ''}` : + item.content || JSON.stringify(item)) : + String(item)} +
  • + ))} +
+
+ )} +
+ ) + )} + + + {reviewPoint.content !== null && Object.keys(reviewPoint.content).length > 0 && ( + <> + {/* 内容显示区域 */} +
+
+ {/* 修改评查结果的结构之后,显示新的结构 */} + {renderContent(reviewPoint, mergedRules)} +
+
+ + )} + +
+ ); + + }; + + /** + * 渲染无匹配结果提示 + * 当过滤后没有评查点时显示 + */ + const renderEmptyState = () => { + return ( +
+ +

没有找到匹配的评查点

+

请尝试不同的搜索词或清除筛选条件

+ {(searchText || statusFilter) && ( + + )} +
+ ); + }; + + // 处理评查点点击事件 + const handleReviewPointClick = (id: string) => { + // 找到被点击的评查点 + const reviewPoint = reviewPoints.find(result => result.id === id); + + // 如果评查点存在 + if (reviewPoint) { + // 如果effectivePages有值,使用它 + if (reviewPoint.id && effectivePages[reviewPoint.id]) { + // console.log('effectivePages', effectivePages[reviewPoint.id]); + onReviewPointSelect(id, effectivePages[reviewPoint.id]); + // return; + } else { + // 没有有效页码,只传递ID + onReviewPointSelect(id); + } + } else { + // 没有找到评查点,只传递ID + onReviewPointSelect(id); + } + }; + + // 检查评查点的contentPage,如果contentPage内也没有page,则返回默认值 + const checkContentPage = (reviewPoint: ReviewPoint): { pageIndex: number, key?: string, id: string } => { + // 返回对象初始化 + const result = { pageIndex: 0, id: reviewPoint.id }; + + // 如果contentPage不存在或是空对象,返回默认值 + if (!reviewPoint.contentPage || Object.keys(reviewPoint.contentPage).length === 0) { + return result; + } + + // 遍历contentPage中的每个key + for (const key of Object.keys(reviewPoint.contentPage)) { + if (reviewPoint.contentPage[key] && parseInt(reviewPoint.contentPage[key] as string) > 0) { + // 返回第一个找到的有效页码,以及对应的key + return { + pageIndex: parseInt(reviewPoint.contentPage[key] as string), + key, + id: reviewPoint.id + }; + } + } + + // 如果遍历完所有key都没找到有效页码,返回默认值 + return result; + }; + + // 组件主渲染函数 + return ( + <> +
+ + {/* 面板头部 */} +
+ + 评查结果 +
+ + {/* 评查统计 */} + {renderStatistics()} + + {/* 搜索框 */} + {renderSearchBar()} + + {/* 评查点列表 */} +
+ {filteredReviewPoints.length > 0 ? ( + filteredReviewPoints.map(reviewPoint => ( +
{ + // console.log('reviewPoint', reviewPoint); + handleReviewPointClick(reviewPoint.id); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleReviewPointClick(reviewPoint.id); + } + }} + > + {/* 评查点标题和状态 */} + {/* 评查点名称 pointName*/} +
+ {/*
*/} +
{reviewPoint.pointName}
+ {/*
+
{reviewPoint.title}
+ //评查点分组显示 +
+ + + {reviewPoint.groupName} + +
+
*/} + {/*
*/} +
+ {/* 提出意见按钮 */} +
+ +
+ {renderStatusBadge(reviewPoint.status, reviewPoint.result,reviewPoint.title)} +
+
+ + {/* 评查点内容和操作 */} + {renderReviewPointContent(reviewPoint)} + + +
+ )) + ) : ( + renderEmptyState() + )} +
+
+ + {/* 提出意见模态框 */} + + + +
+ } + > +
+ {/* 审查点 */} +
+ + +
+ + {/* 发现问题 */} +
+ + +
+ + {/* 审查意见 */} +
+ +