2891 lines
114 KiB
TypeScript
2891 lines
114 KiB
TypeScript
/**
|
||
* 评查点列表组件
|
||
*
|
||
* 功能概述:
|
||
* - 展示评查结果统计信息(总计、通过、警告、错误数量)
|
||
* - 提供评查点过滤功能(按状态和搜索文本)
|
||
* - 显示评查点详细信息(标题、状态、内容、建议修改等)
|
||
* - 支持评查点操作(一键替换、人工审核等)
|
||
*
|
||
* 组件结构:
|
||
* - 统计区域: 显示评查点数量统计
|
||
* - 搜索区域: 提供文本搜索功能
|
||
* - 评查点列表: 展示所有评查点
|
||
* - 评查点卡片: 展示单个评查点详情
|
||
* - 评查点头部: 显示标题和状态
|
||
* - 评查点内容: 显示当前内容和问题
|
||
* - 建议修改区域: 显示建议的修改内容
|
||
* - 操作按钮: 提供一键替换和人工审核功能
|
||
*/
|
||
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 { CorporateInfoModal } from '../corporate-information';
|
||
import type { BusinessInfoResult, DishonestyResult } from '../corporate-information';
|
||
import { queryCompanyInfo } from '~/api/corporate-information/qichacha';
|
||
import { ScoredResultCard } from '~/components/evaluation';
|
||
// import '../../styles/components/TooltipStyles.css';
|
||
|
||
/**
|
||
* 比较方法映射
|
||
* 将后端返回的比较方法英文值映射为友好的中文显示
|
||
*/
|
||
const compareMethodMap: Record<string, string> = {
|
||
'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<string, string> = {
|
||
'exists': '有无判断',
|
||
'format': '格式判断',
|
||
'logic': '逻辑判断',
|
||
'regex': '正则表达式',
|
||
// 可以根据需要添加更多映射
|
||
};
|
||
|
||
/**
|
||
* 获取规则类型的中文显示
|
||
* @param type 规则类型的原始值
|
||
* @returns 映射后的中文显示文本
|
||
*/
|
||
const getRuleTypeText = (type?: string): string => {
|
||
if (!type) return '';
|
||
return ruleTypeMap[type] || type;
|
||
};
|
||
|
||
/**
|
||
* 字符位置类型定义
|
||
* 用于定位文档中具体的文字位置
|
||
*/
|
||
export interface CharPosition {
|
||
box: number[][]; // 字符边界框坐标
|
||
char: string; // 字符内容
|
||
score: number; // OCR识别置信度
|
||
}
|
||
|
||
/**
|
||
* 评查点类型定义
|
||
* 用于展示单个评查结果
|
||
*/
|
||
export interface ReviewPoint {
|
||
id: string;
|
||
documentId?: string;
|
||
pointId?: string;
|
||
editAuditStatusId?: string | number;
|
||
editAuditStatus: number;
|
||
editAuditStatusMessage?: string; // 添加审核意见字段
|
||
pointName: string;
|
||
pointCode?: string;
|
||
title: string;
|
||
groupName: string;
|
||
status: string;
|
||
content: Record<string, { page?: number | string, value?: object }>;
|
||
suggestion: string;
|
||
needsHumanReview?: boolean;
|
||
humanReviewNote?: string;
|
||
humanReviewBy?: string;
|
||
humanReviewTime?: string;
|
||
contentPage?: Record<string, number | string>;
|
||
position?: {
|
||
section: string;
|
||
index: number;
|
||
};
|
||
result?: boolean;
|
||
legalBasis?: {
|
||
name?: string;
|
||
content?: string;
|
||
articles?: Array<string | { name?: string; content?: string;[key: string]: unknown }>;
|
||
[key: string]: unknown;
|
||
};
|
||
postAction?: string;
|
||
actionContent?: string;
|
||
score?: number;
|
||
machineScore?: number;
|
||
finalScore?: number | null;
|
||
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<string, unknown>;
|
||
}>;
|
||
};
|
||
}
|
||
|
||
// 统计数据类型
|
||
interface Statistics {
|
||
total: number;
|
||
success: number;
|
||
warning: number;
|
||
error: number;
|
||
score: number;
|
||
}
|
||
|
||
// GraphRAG Scored 评查结果类型
|
||
interface FieldScore {
|
||
field_path: string;
|
||
evaluation_as: string;
|
||
weight: number;
|
||
scored: number;
|
||
max_score: number;
|
||
status: string;
|
||
value: string;
|
||
page?: string;
|
||
ai_feedback?: string;
|
||
}
|
||
|
||
interface ScoredEvaluationResult {
|
||
evaluation_point_id: number;
|
||
code: string;
|
||
name: string;
|
||
passed: boolean;
|
||
machine_score: number;
|
||
score: number;
|
||
percentage: number;
|
||
total_score: number;
|
||
total_weight: number;
|
||
pass_threshold: number;
|
||
result_type: 'scored';
|
||
field_results: FieldScore[];
|
||
missing_fields?: string[];
|
||
ai_suggestion?: string;
|
||
}
|
||
|
||
interface EvaluationSummary {
|
||
total_points: number;
|
||
passed_count: number;
|
||
failed_count: number;
|
||
total_score: number;
|
||
total_full_score: number;
|
||
average_percentage: number;
|
||
}
|
||
|
||
interface ReviewPointsListProps {
|
||
reviewPoints: ReviewPoint[];
|
||
statistics: Statistics;
|
||
activeReviewPointResultId: string | null;
|
||
onReviewPointSelect: (id: string, page?: number, charPositions?: CharPosition[], value?: string) => void;
|
||
onStatusChange: (id: string, editAuditStatusId: string | number, status: string, message: string) => void;
|
||
fileFormat?: string; // 文档格式类型(PDF、DOCX等)
|
||
onAiSuggestionReplace?: (searchText: string, replaceText: string, pageNumber: number) => void; // AI建议替换回调
|
||
// GraphRAG Scored 模式支持
|
||
flowType?: 'graphrag' | 'legacy';
|
||
scoredResults?: ScoredEvaluationResult[];
|
||
scoredSummary?: EvaluationSummary;
|
||
}
|
||
|
||
/**
|
||
* 全局状态对象,存储当前活动的提示框信息
|
||
* 这种方式避免了复杂的状态提升或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(
|
||
<div
|
||
className="fixed bg-white shadow-lg rounded-md p-1 border border-gray-200 z-[9999]"
|
||
style={{
|
||
top: `${tooltip.position.top}px`,
|
||
left: `${tooltip.position.left}px`,
|
||
transform: 'translate(-100%, -50%)', // 调整位置,使提示框在指针左侧居中显示
|
||
opacity: tooltip.ready ? 1 : 0, // 根据ready状态控制透明度
|
||
visibility: tooltip.ready ? 'visible' : 'hidden', // 使用visibility确保在位置计算时元素存在但不可见
|
||
transition: 'opacity 0.15s ease-out' // 添加平滑过渡效果
|
||
}}
|
||
>
|
||
{tooltip.content}
|
||
{/* 添加小三角形指向提示框指向的元素 */}
|
||
<div className="absolute top-1/2 right-0 transform translate-x-1/2 -translate-y-1/2 rotate-45 w-2 h-2 bg-white border-t border-r border-gray-200"></div>
|
||
</div>,
|
||
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<React.ReactNode>(null);
|
||
const textRef = useRef<HTMLDivElement>(null);
|
||
|
||
const isTabTable = content.includes('\t') && content.includes('\n');
|
||
// 检测 markdown 表格:有 |---| 分隔行,或 pipe 分隔 + 换行(无表头的 pipe 表格)
|
||
const isMdTable = content.includes('|') && /\|[-\s:]+\|/.test(content);
|
||
const isPipeTable = !isMdTable && content.includes('|') && content.includes('\n')
|
||
&& content.split('\n').filter(l => l.includes('|')).length >= 2;
|
||
const isTableLike = isTabTable || isMdTable || isPipeTable;
|
||
|
||
useEffect(() => {
|
||
const checkTextOverflow = () => {
|
||
const element = textRef.current;
|
||
if (element) {
|
||
// 如果是表格格式,总是显示tooltip;否则只在文本溢出时显示
|
||
setShowTooltip(isTableLike || element.scrollHeight > element.clientHeight);
|
||
}
|
||
};
|
||
|
||
// 预渲染内容并缓存
|
||
if (isMdTable) {
|
||
setRenderedContent(renderMarkdownTable(content));
|
||
} else if (isPipeTable) {
|
||
setRenderedContent(renderPipeTable(content));
|
||
} else if (isTabTable) {
|
||
setRenderedContent(renderReactTable(content));
|
||
} else {
|
||
setRenderedContent(content);
|
||
}
|
||
|
||
requestAnimationFrame(checkTextOverflow);
|
||
window.addEventListener('resize', checkTextOverflow);
|
||
return () => {
|
||
window.removeEventListener('resize', checkTextOverflow);
|
||
};
|
||
}, [content, isTableLike]);
|
||
|
||
// 解析表格数据(tab分隔)
|
||
const parseTableData = (text: string) => {
|
||
const rows = text.split('\n').map(row => row.split('\t'));
|
||
return rows;
|
||
};
|
||
|
||
// 解析 markdown 表格(支持多行和单行格式)
|
||
const parseMarkdownTable = (text: string): string[][] => {
|
||
// 先尝试按换行分割
|
||
const lines = text.split('\n').filter(l => l.trim());
|
||
|
||
// 多行格式:有多行且包含分隔行
|
||
if (lines.length > 2) {
|
||
const rows: string[][] = [];
|
||
for (const line of lines) {
|
||
if (/^\s*\|[-\s:]+\|/.test(line)) continue; // 跳过分隔行
|
||
const cells = line.split('|').map(c => c.trim()).filter((_, i, arr) => i > 0 && i < arr.length);
|
||
if (cells.length > 0) rows.push(cells);
|
||
}
|
||
if (rows.length > 1) return rows;
|
||
}
|
||
|
||
// 单行格式:整个表格在一行内,通过分隔行 |---|---| 来拆分
|
||
const sepMatch = text.match(/\|[\s-:]+(?:\|[\s-:]+)+\|/);
|
||
if (!sepMatch) return [];
|
||
|
||
const colCount = (sepMatch[0].match(/---/g) || []).length;
|
||
if (colCount === 0) return [];
|
||
|
||
const sepIdx = text.indexOf(sepMatch[0]);
|
||
const headerPart = text.substring(0, sepIdx);
|
||
const bodyPart = text.substring(sepIdx + sepMatch[0].length);
|
||
|
||
// 解析 header
|
||
const headerCells = headerPart.split('|').map(c => c.trim()).filter(c => c);
|
||
// 解析 body:按 | 分割后每 colCount 个cell为一行
|
||
const bodyCells = bodyPart.split('|').map(c => c.trim()).filter(c => c);
|
||
const rows: string[][] = [headerCells];
|
||
for (let i = 0; i < bodyCells.length; i += colCount) {
|
||
const row = bodyCells.slice(i, i + colCount);
|
||
if (row.length > 0) rows.push(row);
|
||
}
|
||
return rows;
|
||
};
|
||
|
||
// 渲染 markdown 表格
|
||
const renderMarkdownTable = (text: string) => {
|
||
try {
|
||
const tableData = parseMarkdownTable(text);
|
||
if (tableData.length === 0) return content;
|
||
|
||
return (
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full border-collapse border border-gray-300">
|
||
<thead>
|
||
<tr>
|
||
{tableData[0].map((cell, cellIndex) => (
|
||
<th
|
||
key={`header-${cellIndex}`}
|
||
className="px-2 py-1 border border-gray-300 bg-gray-100 font-medium text-xs text-left whitespace-nowrap"
|
||
>
|
||
{cell || ' '}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{tableData.slice(1).map((row, rowIndex) => (
|
||
<tr key={`row-${rowIndex}`}>
|
||
{row.map((cell, cellIndex) => (
|
||
<td
|
||
key={`cell-${rowIndex}-${cellIndex}`}
|
||
className="px-2 py-1 border border-gray-300 text-xs text-left"
|
||
>
|
||
{cell || ' '}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
} catch (error) {
|
||
console.error('Markdown表格渲染错误:', error);
|
||
return <div>{content}</div>;
|
||
}
|
||
};
|
||
|
||
// 渲染 pipe 分隔表格(无表头,如 "1 | 名称 | 项 | 1\n2 | ...")
|
||
const renderPipeTable = (text: string) => {
|
||
try {
|
||
const rows = text.split('\n')
|
||
.filter(l => l.trim() && l.includes('|'))
|
||
.map(line => line.split('|').map(c => c.trim()));
|
||
if (rows.length === 0) return <div>{content}</div>;
|
||
return (
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full border-collapse border border-gray-300">
|
||
<tbody>
|
||
{rows.map((row, rowIndex) => (
|
||
<tr key={`row-${rowIndex}`}>
|
||
{row.map((cell, cellIndex) => (
|
||
<td
|
||
key={`cell-${rowIndex}-${cellIndex}`}
|
||
className="px-2 py-1 border border-gray-300 text-xs text-left"
|
||
>
|
||
{cell || ' '}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
} catch (error) {
|
||
console.error('Pipe表格渲染错误:', error);
|
||
return <div>{content}</div>;
|
||
}
|
||
};
|
||
|
||
// 渲染React表格(tab分隔)
|
||
const renderReactTable = (text: string) => {
|
||
try {
|
||
const tableData = parseTableData(text);
|
||
const hasHeader = tableData.length > 0;
|
||
|
||
return (
|
||
<div>
|
||
<table className="min-w-full border-collapse border border-gray-300">
|
||
{hasHeader && (
|
||
<thead>
|
||
<tr>
|
||
{tableData[0].map((cell, cellIndex) => (
|
||
<th
|
||
key={`header-${cellIndex}`}
|
||
className="px-2 py-1 border border-gray-300 bg-gray-100 font-medium text-xs text-left"
|
||
>
|
||
{cell || ' '}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
)}
|
||
<tbody>
|
||
{tableData.slice(1).map((row, rowIndex) => (
|
||
<tr key={`row-${rowIndex}`}>
|
||
{row.map((cell, cellIndex) => (
|
||
<td
|
||
key={`cell-${rowIndex}-${cellIndex}`}
|
||
className="px-2 py-1 border border-gray-300 text-xs text-left"
|
||
>
|
||
{cell || ' '}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
} catch (error) {
|
||
console.error('表格渲染错误:', error);
|
||
return <div className="text-red-500">表格渲染错误</div>;
|
||
}
|
||
};
|
||
|
||
|
||
|
||
// 表格内容:主区域显示摘要,hover 悬浮显示完整表格
|
||
if (isTableLike) {
|
||
// 生成摘要文本:取第一行数据作为预览
|
||
const summaryText = (() => {
|
||
const firstLine = content.split('\n').find(l => l.trim() && !/^\s*\|[-\s:]+\|/.test(l));
|
||
return firstLine ? firstLine.trim().substring(0, 80) + (firstLine.length > 80 ? '...' : '') : content.substring(0, 80);
|
||
})();
|
||
|
||
// 根据列数动态计算宽度:每列约 120px,最小 400,最大 90vw
|
||
const colCount = (() => {
|
||
if (isMdTable) {
|
||
const sepMatch = content.match(/\|[\s-:]+(?:\|[\s-:]+)+\|/);
|
||
return sepMatch ? (sepMatch[0].match(/---/g) || []).length : 4;
|
||
}
|
||
const firstDataLine = content.split('\n').find(l => l.includes('|'));
|
||
return firstDataLine ? firstDataLine.split('|').filter(c => c.trim()).length : 4;
|
||
})();
|
||
const tableMaxWidth = Math.min(Math.max(colCount * 140, 400), window.innerWidth * 0.85);
|
||
|
||
// 高度不限制,让表格内容完整显示,最大 80vh
|
||
const tableMaxHeight = window.innerHeight * 0.8;
|
||
|
||
return (
|
||
<div className="text-xs p-1 rounded cursor-text w-full text-left">
|
||
<Tooltip
|
||
content={renderedContent}
|
||
placement="top"
|
||
theme="light"
|
||
trigger="hover"
|
||
showArrow={true}
|
||
className="tooltip-custom-offset"
|
||
scrollable={true}
|
||
maxWidth={tableMaxWidth}
|
||
maxHeight={tableMaxHeight}
|
||
>
|
||
<div className="text-gray-800 break-all overflow-hidden line-clamp-2 flex items-center gap-1">
|
||
<i className="ri-table-line text-blue-400 flex-shrink-0"></i>
|
||
<span>{summaryText}</span>
|
||
</div>
|
||
</Tooltip>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="text-xs p-1 rounded cursor-text w-full text-left">
|
||
{showTooltip ? (
|
||
<Tooltip
|
||
content={renderedContent}
|
||
placement="top"
|
||
theme="light"
|
||
trigger="hover"
|
||
showArrow={true}
|
||
className="tooltip-custom-offset"
|
||
// fixedPlacement={true}
|
||
scrollable={true}
|
||
maxHeight={400}
|
||
>
|
||
<div className="text-gray-800 break-all overflow-hidden line-clamp-2" ref={textRef}>
|
||
{content}
|
||
</div>
|
||
</Tooltip>
|
||
) : (
|
||
<div className="text-gray-800 break-all overflow-hidden line-clamp-2" ref={textRef}>
|
||
{content}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export function ReviewPointsList({
|
||
reviewPoints,
|
||
statistics,
|
||
activeReviewPointResultId,
|
||
onReviewPointSelect,
|
||
onStatusChange,
|
||
fileFormat,
|
||
onAiSuggestionReplace,
|
||
flowType,
|
||
scoredResults,
|
||
scoredSummary
|
||
}: ReviewPointsListProps) {
|
||
// 状态管理
|
||
const [editingReviewPoint, setEditingReviewPoint] = useState<string | null>(null); // 当前正在编辑的评查点ID
|
||
const [searchText, setSearchText] = useState(''); // 搜索文本
|
||
const [statusFilter, setStatusFilter] = useState<string | null>(null); // 状态过滤
|
||
|
||
// const [suggestionTexts, setSuggestionTexts] = useState<Record<string, string>>({}); // 存储每个评查点的建议文本
|
||
|
||
// 添加重新审核意见的状态/ 用户输入的修改内容 / 用户提前写好的修改内容
|
||
const [manualReviewNotes, setManualReviewNotes] = useState<Record<string, string>>({});
|
||
|
||
// 存放评查点ID与有效页码的映射
|
||
const [effectivePages, setEffectivePages] = useState<Record<string, number>>({});
|
||
|
||
// 企业信息模态框状态
|
||
const [corporateModalVisible, setCorporateModalVisible] = useState(false);
|
||
const [corporateCompanyName, setCorporateCompanyName] = useState('');
|
||
const [corporateBusinessInfo, setCorporateBusinessInfo] = useState<BusinessInfoResult | null>(null);
|
||
const [corporateDishonestyInfo, setCorporateDishonestyInfo] = useState<DishonestyResult | null>(null);
|
||
const [corporateLoading, setCorporateLoading] = useState(false);
|
||
const [corporateError, setCorporateError] = useState<string | null>(null);
|
||
const [corporateUpdatedAt, setCorporateUpdatedAt] = useState<string | null>(null);
|
||
|
||
// 初始化建议文本
|
||
useEffect(() => {
|
||
// 使用函数式更新,不再需要外部 manualReviewNotes 变量
|
||
setManualReviewNotes(prev => {
|
||
const notes = { ...prev };
|
||
reviewPoints.forEach(point => {
|
||
// 优先使用editAuditStatusMessage,如果为空则使用actionContent或suggestion
|
||
notes[point.id] = point.editAuditStatusMessage || point.actionContent || point.suggestion || '';
|
||
});
|
||
return notes;
|
||
});
|
||
}, [reviewPoints]);
|
||
|
||
// 处理建议文本变更
|
||
// const handleSuggestionChange = (reviewPointId: string, text: string) => {
|
||
// setSuggestionTexts(prev => ({
|
||
// ...prev,
|
||
// [reviewPointId]: text
|
||
// }));
|
||
// };
|
||
|
||
/**
|
||
* 处理评查点审核操作
|
||
* @param reviewPointResultId 评查点结果ID
|
||
* @param editAuditStatusId 审核状态记录ID
|
||
* @param action 操作类型: 'approve' 通过 / 'reject' 不通过 / 'review' 重新审核
|
||
* @param message 用户输入的审核内容
|
||
*/
|
||
const handleReviewAction = (reviewPointResultId: string, editAuditStatusId: string | number | undefined, action: 'approve' | 'reject' | 'review', message: string) => {
|
||
// 通过/不通过时,必须有输入内容
|
||
if(action !== 'review' && message.trim() === ''){
|
||
toastService.error('请输入审核意见');
|
||
return;
|
||
}
|
||
|
||
if (action === 'review') {
|
||
// 重新审核时,不更新结果状态,也不更新审核意见和审核状态
|
||
onStatusChange(reviewPointResultId, editAuditStatusId || '', 'review', message);
|
||
|
||
// 找到当前评查点并更新其editAuditStatus为0,使其立即显示通过/不通过按钮
|
||
const updatedReviewPoint = reviewPoints.find(point => point.id === reviewPointResultId);
|
||
if (updatedReviewPoint) {
|
||
updatedReviewPoint.editAuditStatus = 0;
|
||
// 重新审核时不更新输入框内容
|
||
}
|
||
} else {
|
||
// 通过/不通过时,更新结果状态和审核意见
|
||
onStatusChange(reviewPointResultId, editAuditStatusId || '', action === 'approve' ? 'true' : 'false', message);
|
||
|
||
// 找到当前评查点并立即更新其editAuditStatusMessage
|
||
const updatedReviewPoint = reviewPoints.find(point => point.id === reviewPointResultId);
|
||
if (updatedReviewPoint) {
|
||
updatedReviewPoint.editAuditStatusMessage = message;
|
||
}
|
||
}
|
||
|
||
// 清除编辑状态
|
||
setEditingReviewPoint(null);
|
||
};
|
||
|
||
/**
|
||
* 处理企业信息按钮点击
|
||
* @param companyName 企业名称(乙方名称)
|
||
* @param forceRefresh 是否强制刷新(对接企查查重新查询)
|
||
*/
|
||
const handleCorporateInfoClick = async (companyName: string, forceRefresh = false) => {
|
||
if (!companyName) {
|
||
toastService.warning('企业名称为空,无法查询');
|
||
return;
|
||
}
|
||
|
||
// 打开模态框并设置加载状态
|
||
setCorporateModalVisible(true);
|
||
setCorporateCompanyName(companyName);
|
||
setCorporateLoading(true);
|
||
setCorporateError(null);
|
||
setCorporateBusinessInfo(null);
|
||
setCorporateDishonestyInfo(null);
|
||
setCorporateUpdatedAt(null);
|
||
|
||
try {
|
||
const response = await queryCompanyInfo({ keyword: companyName, force_refresh: forceRefresh });
|
||
|
||
if (response.success && response.data) {
|
||
setCorporateBusinessInfo(response.data.enterprise);
|
||
setCorporateUpdatedAt(response.data.updated_at);
|
||
// 转换失信数据格式
|
||
if (response.data.dishonesty) {
|
||
setCorporateDishonestyInfo({
|
||
VerifyResult: response.data.dishonesty.VerifyResult,
|
||
Data: response.data.dishonesty.Data || [],
|
||
});
|
||
}
|
||
} else {
|
||
setCorporateError(response.message || '查询失败');
|
||
}
|
||
} catch (error) {
|
||
console.error('查询企业信息失败:', error);
|
||
setCorporateError(error instanceof Error ? error.message : '查询失败');
|
||
} finally {
|
||
setCorporateLoading(false);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 处理强制刷新(对接企查查重新查询)
|
||
*/
|
||
const handleCorporateForceRefresh = async () => {
|
||
if (corporateCompanyName) {
|
||
await handleCorporateInfoClick(corporateCompanyName, true);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 关闭企业信息模态框
|
||
*/
|
||
const handleCloseCorporateModal = () => {
|
||
setCorporateModalVisible(false);
|
||
setCorporateCompanyName('');
|
||
setCorporateBusinessInfo(null);
|
||
setCorporateDishonestyInfo(null);
|
||
setCorporateError(null);
|
||
setCorporateUpdatedAt(null);
|
||
};
|
||
|
||
/**
|
||
* 过滤评查点
|
||
* 根据搜索文本和状态过滤条件筛选评查点
|
||
*/
|
||
const filteredReviewPoints = reviewPoints.filter(point => {
|
||
// 匹配搜索文本
|
||
const matchesSearch = searchText === '' ||
|
||
point.pointName.toLowerCase().includes(searchText.toLowerCase()) ||
|
||
(point.pointCode && point.pointCode.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';
|
||
} else if (statusFilter === 'notApplicable') {
|
||
// 过滤"未涉及"状态
|
||
matchesStatus = point.status === 'notApplicable' || point.status === 'not_applicable';
|
||
}
|
||
// console.log('筛选point', point);
|
||
|
||
return matchesSearch && matchesStatus;
|
||
});
|
||
// console.log('筛选filteredReviewPoints', filteredReviewPoints);
|
||
|
||
/**
|
||
* 处理一键替换操作
|
||
* @param reviewPointId 评查点ID
|
||
*/
|
||
const handleReplace = (reviewPointId: string) => {
|
||
// 在实际应用中,这里应该调用API进行内容替换
|
||
// 模拟替换操作
|
||
alert(`将为评查点 ${reviewPointId} 执行一键替换操作`);
|
||
|
||
// 更新评查点状态为成功
|
||
// onStatusChange(reviewPointId, 'success');
|
||
};
|
||
|
||
/**
|
||
* 渲染评查统计信息
|
||
* 显示总计、通过、警告、错误数量
|
||
*/
|
||
const renderStatistics = () => {
|
||
// 确保传入的statistics存在,否则使用计算值
|
||
const statsToUse = statistics || {
|
||
total: reviewPoints.length,
|
||
success: 0,
|
||
warning: 0,
|
||
error: 0,
|
||
notApplicable: 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;
|
||
const notApplicableCount = reviewPoints.filter(
|
||
point => point.status === 'notApplicable' || point.status === 'not_applicable'
|
||
).length;
|
||
const notApplicableToShow = notApplicableCount || statsToUse.notApplicable || 0;
|
||
|
||
return (
|
||
<div className="review-statistics bg-white border-b border-gray-100 py-3 px-4">
|
||
<div className="flex justify-between items-center">
|
||
{/* 总计数量 */}
|
||
<div className="flex items-center">
|
||
<button
|
||
className={`px-3 h-7 bg-gray-100 rounded-md flex items-center justify-center cursor-pointer ${statusFilter === null && searchText === '' ? 'ring-2 ring-gray-400' : ''}`}
|
||
onClick={() => {
|
||
setStatusFilter(null);
|
||
setSearchText('');
|
||
}}
|
||
aria-label="显示所有评查点"
|
||
type="button"
|
||
>
|
||
<span className="text-sm font-semibold text-gray-600">{totalToShow}</span>
|
||
<span className="text-xs text-gray-500 ml-1">总计</span>
|
||
</button>
|
||
</div>
|
||
<div className="h-8 border-r border-gray-200"></div>
|
||
{/* 通过数量 */}
|
||
<div className="flex items-center">
|
||
<button
|
||
className={`px-3 h-7 bg-green-50 rounded-md flex items-center justify-center cursor-pointer ${statusFilter === 'success' ? 'ring-2 ring-green-500' : ''}`}
|
||
onClick={() => setStatusFilter(statusFilter === 'success' ? null : 'success')}
|
||
aria-label={`过滤通过项 ${statusFilter === 'success' ? '(已选中)' : ''}`}
|
||
type="button"
|
||
>
|
||
<span className="text-sm font-semibold text-success">{successToShow}</span>
|
||
<span className="text-xs text-gray-500 ml-2">通过</span>
|
||
</button>
|
||
</div>
|
||
<div className="h-8 border-r border-gray-200"></div>
|
||
{/* 警告数量 */}
|
||
<div className="flex items-center">
|
||
<button
|
||
className={`px-3 h-7 bg-yellow-50 rounded-md flex items-center justify-center cursor-pointer ${statusFilter === 'warning' ? 'ring-2 ring-yellow-500' : ''}`}
|
||
onClick={() => setStatusFilter(statusFilter === 'warning' ? null : 'warning')}
|
||
aria-label={`过滤警告项 ${statusFilter === 'warning' ? '(已选中)' : ''}`}
|
||
type="button"
|
||
>
|
||
<span className="text-sm font-semibold text-warning">{warningToShow}</span>
|
||
<span className="text-xs text-gray-500 ml-2">警告</span>
|
||
</button>
|
||
</div>
|
||
<div className="h-8 border-r border-gray-200"></div>
|
||
{/* 错误数量 */}
|
||
<div className="flex items-center">
|
||
<button
|
||
className={`px-3 h-7 bg-red-50 rounded-md flex items-center justify-center cursor-pointer ${statusFilter === 'error' ? 'ring-2 ring-red-500' : ''}`}
|
||
onClick={() => setStatusFilter(statusFilter === 'error' ? null : 'error')}
|
||
aria-label={`过滤错误项 ${statusFilter === 'error' ? '(已选中)' : ''}`}
|
||
type="button"
|
||
>
|
||
<span className="text-sm font-semibold text-error">{errorToShow}</span>
|
||
<span className="text-xs text-gray-500 ml-2">错误</span>
|
||
</button>
|
||
</div>
|
||
{/* 未涉及数量 */}
|
||
<div className="h-8 border-r border-gray-200"></div>
|
||
<div className="flex items-center">
|
||
<button
|
||
className={`px-3 h-7 bg-blue-50 rounded-md flex items-center justify-center cursor-pointer ${statusFilter === 'notApplicable' ? 'ring-2 ring-blue-400' : ''}`}
|
||
onClick={() => setStatusFilter(statusFilter === 'notApplicable' ? null : 'notApplicable')}
|
||
aria-label={`过滤未涉及项 ${statusFilter === 'notApplicable' ? '(已选中)' : ''}`}
|
||
type="button"
|
||
>
|
||
<span className="text-sm font-semibold text-blue-500">{notApplicableToShow}</span>
|
||
<span className="text-xs text-gray-500 ml-2">未涉及</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 渲染搜索框
|
||
* 用于按文本搜索评查点
|
||
*/
|
||
const renderSearchBar = () => {
|
||
return (
|
||
<div className="py-2 px-3 border-b border-gray-100">
|
||
<div className="relative">
|
||
<input
|
||
type="text"
|
||
className="w-full border border-gray-200 rounded-md pl-8 pr-2 py-1 text-xs h-7
|
||
focus:outline-none focus:ring-1 focus:ring-green-800"
|
||
placeholder="搜索评查点名称或编码..."
|
||
value={searchText}
|
||
onChange={(e) => setSearchText(e.target.value)}
|
||
/>
|
||
<i className="ri-search-line absolute left-2 top-0.5 text-gray-400"></i>
|
||
{searchText && (
|
||
<button
|
||
className="absolute right-2 top-0.5 text-gray-400 hover:text-gray-600"
|
||
onClick={() => setSearchText('')}
|
||
>
|
||
<i className="ri-close-line"></i>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 渲染评查点状态标签
|
||
* @param status 状态文本
|
||
* @param result 评查结果
|
||
* @param title 标签提示内容
|
||
* @returns 状态标签组件
|
||
*/
|
||
const renderStatusBadge = (status: string, result?: boolean, title?: string) => {
|
||
// 优先根据result判断是否通过
|
||
if (result === true) {
|
||
return (
|
||
<Tooltip
|
||
content={
|
||
<div className="p-1 gap-1 flex flex-col max-w-xs">
|
||
{title && <div className="text-xs text-gray-600">{title}</div>}
|
||
</div>
|
||
}
|
||
placement="top"
|
||
theme="light"
|
||
trigger="hover"
|
||
showArrow={true}
|
||
className="tooltip-custom-offset tooltip-top"
|
||
fixedPlacement={true}
|
||
>
|
||
<span className="status-badge status-success text-xs m-1">
|
||
<i className="ri-checkbox-circle-line mr-1"></i>通过
|
||
</span>
|
||
</Tooltip>
|
||
);
|
||
}
|
||
|
||
// 当result为false时,根据status决定显示警告还是错误
|
||
if (result === false) {
|
||
if (status === 'warning' || status === 'info') {
|
||
return (
|
||
<Tooltip
|
||
content={
|
||
<div className="p-1 flex flex-col gap-1 max-w-xs">
|
||
{title && <div className="text-xs text-gray-600">{title}</div>}
|
||
</div>
|
||
}
|
||
placement="top"
|
||
theme="light"
|
||
trigger="hover"
|
||
showArrow={true}
|
||
className="tooltip-custom-offset tooltip-top"
|
||
fixedPlacement={true}
|
||
>
|
||
<span className="status-badge status-warning text-xs m-1">
|
||
<i className="ri-alert-line mr-1"></i>警告
|
||
</span>
|
||
</Tooltip>
|
||
);
|
||
} else if (status === 'error') {
|
||
return (
|
||
<Tooltip
|
||
content={
|
||
<div className="p-1 flex flex-col gap-1 max-w-xs">
|
||
{title && <div className="text-xs text-gray-600">{title}</div>}
|
||
</div>
|
||
}
|
||
placement="top"
|
||
theme="light"
|
||
trigger="hover"
|
||
showArrow={true}
|
||
className="tooltip-custom-offset tooltip-top"
|
||
fixedPlacement={true}
|
||
>
|
||
<span className="status-badge status-error text-xs m-1">
|
||
<i className="ri-close-circle-line mr-1"></i>不通过
|
||
</span>
|
||
</Tooltip>
|
||
);
|
||
}
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 渲染人工审核标记
|
||
* @param reviewPoint 评查点
|
||
* @returns 人工审核标记组件
|
||
*/
|
||
const renderHumanReviewBadge = (reviewPoint: ReviewPoint) => {
|
||
if (reviewPoint.postAction === 'manual') {
|
||
return (
|
||
<span className=" bg-[#f9f0ff] text-[#722ed1] text-xs rounded-sm p-0.5 my-auto">
|
||
<i className="ri-user-line mr-1"></i>需人工
|
||
</span>
|
||
);
|
||
}
|
||
return null;
|
||
};
|
||
|
||
/**
|
||
* 渲染人工审核注释
|
||
* @param reviewPoint 评查点
|
||
* @returns 人工审核注释组件
|
||
*/
|
||
// const renderHumanReviewNote = (reviewPoint: ReviewPoint) => {
|
||
// // 目前needsHumanReview和humanReviewNote都为空,所以不显示
|
||
// if (reviewPoint.needsHumanReview && reviewPoint.humanReviewNote) {
|
||
// return (
|
||
// <div className="human-review-note">
|
||
// <i className="ri-information-line mr-1"></i> {reviewPoint.humanReviewNote}
|
||
// {reviewPoint.humanReviewBy && reviewPoint.humanReviewTime && (
|
||
// <div className="text-right text-xs text-gray-500 mt-1">
|
||
// 审核人:{reviewPoint.humanReviewBy} | 时间:{reviewPoint.humanReviewTime}
|
||
// </div>
|
||
// )}
|
||
// </div>
|
||
// );
|
||
// }
|
||
// return null;
|
||
// };
|
||
|
||
/**
|
||
* 渲染评查点主要内容
|
||
* @param reviewPoint 评查点
|
||
* @returns 评查点主要内容组件
|
||
*/
|
||
const renderContent = (reviewPoint: ReviewPoint, otherRules: Array<Record<string, unknown>>) => {
|
||
return (
|
||
<>
|
||
{/* 渲染其他规则分组 */}
|
||
{otherRules.map((rule, index) => {
|
||
return <div key={`other-rule-${index}`}>{renderOtherRule(rule, reviewPoint)}</div>;
|
||
})}
|
||
|
||
{/* <div key="line" className=" bg-gray-50 rounded border border-gray-200 text-xs mb-3"></div> */}
|
||
{/* 渲染各个一致性的规则分组 */}
|
||
{reviewPoint.evaluatedPointResultsLog?.rules?.map((rule, index) => {
|
||
// console.log('rule-------', rule);
|
||
if (rule.type === 'consistency') {
|
||
// if (rule.res === true && reviewPoint.result === true) {
|
||
return <div key={`rule-${index}`}>
|
||
{otherRules.length > 0 && <div key="line" className=" bg-gray-50 rounded border border-gray-200 text-xs mb-3"></div>}
|
||
{renderConsistencyRule(rule, reviewPoint)}
|
||
</div>;
|
||
// }else {
|
||
// return null;
|
||
// }
|
||
}
|
||
|
||
if (rule.type === 'ai') {
|
||
return <div key={`rule-${index}`}>
|
||
{otherRules.length > 0 && <div key="line" className=" bg-gray-50 rounded border border-gray-200 text-xs mb-3"></div>}
|
||
{renderModelRule(rule, reviewPoint)}
|
||
</div>;
|
||
}
|
||
|
||
})}
|
||
|
||
</>
|
||
);
|
||
};
|
||
|
||
|
||
/**
|
||
* 渲染评查点一致性的规则的样式
|
||
* @param singleReviewPoint 一个评查点的一致性规则对象
|
||
* @param reviewPoint 评查点
|
||
* @returns 评查点一致性的规则的样式
|
||
*/
|
||
const renderConsistencyRule = (singleReviewPoint: Record<string, unknown>,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<string, { page: number; value: string; char_positions?: CharPosition[] }>;
|
||
targetField: Record<string, { page: number; value: string; char_positions?: CharPosition[] }>;
|
||
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;
|
||
char_positions?: CharPosition[];
|
||
};
|
||
res: boolean;
|
||
compareMethod?: string;
|
||
};
|
||
|
||
const chains: Array<Array<ChainItem>> = [];
|
||
const visited = new Set<string>();
|
||
|
||
// 构建字段映射关系
|
||
const fieldMap = new Map<string, Array<{
|
||
targetField: string;
|
||
data: {
|
||
source: { key: string; page: number; value: string; char_positions?: CharPosition[] };
|
||
target: { key: string; page: number; value: string; char_positions?: CharPosition[] };
|
||
};
|
||
res: boolean;
|
||
compareMethod?: string;
|
||
}>>();
|
||
|
||
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<string>();
|
||
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<ChainItem> = [];
|
||
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<Array<ChainItem>> = [];
|
||
|
||
// 从后往前遍历,检查每个相邻元素之间的连接
|
||
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<ChainItem> = [
|
||
{
|
||
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 (
|
||
<div className="mt-3">
|
||
<div className="comparison-group">
|
||
{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 (
|
||
<div
|
||
key={`chain_${chainIndex}`}
|
||
className={`${itemClassName} border border-gray
|
||
rounded-md overflow-hidden mb-2 ${res ? 'bg-[rgba(246,255,237,1)]' : 'bg-[rgba(255,251,230,1)]'}
|
||
hover:shadow-[0_0_10px_rgba(0,0,0,0.1)] cursor-pointer ${res ? 'hover:bg-[rgba(0,128,0,0.1)]' : 'hover:bg-[rgba(255,255,0,0.1)]'}`}
|
||
onClick={(e) => {
|
||
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), item.data.char_positions);
|
||
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), item.data.char_positions);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}}
|
||
role="button"
|
||
tabIndex={0}
|
||
>
|
||
<div className="comparison-values flex w-full">
|
||
<div className="value-box p-2 pb-1 flex-1">
|
||
{/* 展示链条 */}
|
||
<div className="value-source text-xs text-gray-500 mb-1">
|
||
{chain.map((item, idx) => (
|
||
<span key={idx} className="inline-block">
|
||
{item.field}
|
||
{idx < chain.length - 1 && (
|
||
<i className="ri-arrow-left-s-line text-xs ml-1 text-primary">
|
||
{typeof chain[idx+1].compareMethod === 'object'
|
||
? ''
|
||
: getCompareMethodText(chain[idx+1].compareMethod)}
|
||
<i className="ri-arrow-right-s-line mr-1 text-xs text-primary"></i>
|
||
</i>
|
||
)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
{/* 展示链条的每个元素的内容 */}
|
||
<div className="flex flex-col">
|
||
{chain.map((item, idx) => (
|
||
<button
|
||
key={`item_${idx}`}
|
||
className="value-content p-1 cursor-text text-xs border-b border-dashed border-gray-200 last:border-b-0 text-left w-full rounded transition-colors"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (item.data.page) {
|
||
console.log('点击了长链条评查点', item.data.char_positions, item.data);
|
||
// 假设onReviewPointSelect在作用域内可用
|
||
const reviewPointId = reviewPoint.id as string;
|
||
if (reviewPointId && typeof onReviewPointSelect === 'function') {
|
||
onReviewPointSelect(reviewPointId, Number(item.data.page), item.data.char_positions, item.data.value);
|
||
}
|
||
}
|
||
else if(reviewPoint.contentPage && reviewPoint.contentPage[item.field]){
|
||
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[item.field]), item.data.char_positions, item.data.value);
|
||
}
|
||
else{
|
||
toastService.error(`没有找到${item.field}对应的索引内容`);
|
||
}
|
||
}}
|
||
aria-label={`查看${item.field}内容详情`}
|
||
>
|
||
<div className="flex justify-between w-full">
|
||
<ReactTableTooltip content={item.data.value?.toString() || ''} />
|
||
{!item.data.page && (reviewPoint.contentPage && !reviewPoint.contentPage[item.field]) && (
|
||
<i className="ri-information-line text-red-500 text-xs" title="没有找到对应的文书内容"></i>
|
||
)}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="status-indicator w-8 flex items-center justify-center group relative" >
|
||
{res ? (
|
||
<i className="ri-check-line text-success text-base" ></i>
|
||
) : (
|
||
<i className="ri-alert-line text-warning text-base" ></i>
|
||
)}
|
||
{/* 使用鼠标事件处理悬停提示 */}
|
||
<div
|
||
className="w-full h-full absolute top-0 left-0"
|
||
onMouseEnter={(e) => {
|
||
// 获取元素位置信息
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
// 创建提示框内容
|
||
const content = (
|
||
<div className="flex flex-row gap-2 overflow-x-auto">
|
||
{chain.map((item, idx) =>
|
||
idx >= 1 ? (
|
||
<div key={idx} className={`rounded-md flex flex-row items-center`}>
|
||
<div className="text-xs text-gray-600 whitespace-nowrap pl-2">
|
||
{typeof item.compareMethod === 'object'
|
||
? ''
|
||
: `${getCompareMethodText(item.compareMethod)}:`}
|
||
</div>
|
||
<div className={`p-1 text-xs rounded-full min-w-[50px] text-center`}>
|
||
{res ? '通过' : '不通过'}
|
||
</div>
|
||
</div>
|
||
) : null
|
||
)}
|
||
</div>
|
||
);
|
||
// 显示提示框
|
||
showTooltip(content, { top: rect.top + rect.height/2, left: rect.left });
|
||
}}
|
||
onMouseLeave={hideTooltip}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 如果是标准的成对比较(2个元素)
|
||
return (
|
||
<div
|
||
key={`pair_${chainIndex}`}
|
||
className={`${itemClassName} border border-gray rounded-md overflow-hidden mb-2 ${res ? 'bg-[rgba(246,255,237,1)]' : 'bg-[rgba(255,251,230,1)]'} flex`}
|
||
>
|
||
<div className="field-label bg-gray-50 p-2 text-xs font-medium text-gray-500 border-r border-gray-200 min-w-[70px] items-center hidden">
|
||
{chain[0].field.split('-').pop()}
|
||
</div>
|
||
<div className="comparison-values flex flex-1">
|
||
<button
|
||
className={`value-box hover:shadow-[0_0_10px_rgba(0,0,0,0.1)]
|
||
flex-1 p-2 border-r-2 ${res ? 'border-green-200' : 'border-yellow-200'} text-left
|
||
${res ? 'hover:bg-[rgba(0,128,0,0.1)]' : 'hover:bg-[rgba(255,255,0,0.1)]'} transition-colors flex flex-col`}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
console.log('点击了短链1左', chain[0].data.char_positions, chain[0].data)
|
||
if (chain[0].data.page) {
|
||
// console.log('点击了短链1左', chain[0].data.char_positions, chain[0].data)
|
||
const reviewPointId = reviewPoint.id as string;
|
||
if (reviewPointId && typeof onReviewPointSelect === 'function') {
|
||
onReviewPointSelect(reviewPointId, chain[0].data.page, chain[0].data.char_positions, chain[0].data.value);
|
||
}
|
||
}
|
||
else if(reviewPoint.contentPage && reviewPoint.contentPage[chain[0].field]){
|
||
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[0].field]), chain[0].data.char_positions,chain[0].data.value);
|
||
}
|
||
else{
|
||
toastService.error(`没有找到${chain[0].field}对应的索引内容`);
|
||
}
|
||
}}
|
||
aria-label={`查看${chain[0].field}内容详情`}
|
||
>
|
||
<div className="value-source text-xs text-gray-500 mb-1">{chain[0].field}
|
||
{!chain[0].data.page && (reviewPoint.contentPage && !reviewPoint.contentPage[chain[0].field]) && (
|
||
<i className="ri-information-line text-red-500 text-xs ml-1" title="没有找到对应的文书内容"></i>
|
||
)}
|
||
</div>
|
||
<ReactTableTooltip content={chain[0].data.value?.toString() || ''} />
|
||
</button>
|
||
<button
|
||
className={`value-box flex flex-col flex-1 p-2 text-left ${res ? 'hover:bg-[rgba(0,128,0,0.1)]' : 'hover:bg-[rgba(255,255,0,0.1)]'} transition-colors hover:shadow-[0_0_10px_rgba(0,0,0,0.1)]`}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (chain[1].data.page) {
|
||
console.log('点击了短链2右', chain[1].data.char_positions, chain[1].data)
|
||
const reviewPointId = reviewPoint.id as string;
|
||
if (reviewPointId && typeof onReviewPointSelect === 'function') {
|
||
onReviewPointSelect(reviewPointId, chain[1].data.page, chain[1].data.char_positions, chain[1].data.value);
|
||
}
|
||
}
|
||
else if(reviewPoint.contentPage && reviewPoint.contentPage[chain[1].field]){
|
||
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[1].field]), chain[1].data.char_positions, chain[1].data.value);
|
||
}
|
||
else{
|
||
toastService.error(`没有找到${chain[1].field}对应的索引内容`);
|
||
}
|
||
}}
|
||
aria-label={`查看${chain[1].field}内容详情`}
|
||
>
|
||
<div className="value-source text-xs text-gray-500 mb-1">{chain[1].field}
|
||
{!chain[1].data.page && (reviewPoint.contentPage && !reviewPoint.contentPage[chain[1].field]) && (
|
||
<i className="ri-information-line text-red-500 text-xs ml-1" title="没有找到对应的文书内容"></i>
|
||
)}
|
||
</div>
|
||
<ReactTableTooltip content={chain[1].data.value?.toString() || ''} />
|
||
</button>
|
||
</div>
|
||
<div className="status-indicator tooltip w-8 flex items-center justify-center group relative">
|
||
{res ? (
|
||
<i className="ri-check-line text-success text-base"></i>
|
||
) : (
|
||
<i className="ri-alert-line text-warning text-base"></i>
|
||
)}
|
||
{/* 使用鼠标事件处理悬停提示 */}
|
||
<div
|
||
className="w-full h-full absolute top-0 left-0"
|
||
onMouseEnter={(e) => {
|
||
// 获取元素位置信息
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
// 创建提示框内容
|
||
const content = (
|
||
<div className="flex flex-row gap-2 overflow-x-auto">
|
||
<div key={chain[1].compareMethod} className={`rounded-md flex flex-row items-center`}>
|
||
<div className="text-xs text-gray-600 whitespace-nowrap pl-1">
|
||
{typeof chain[1].compareMethod === 'object'
|
||
? ''
|
||
: `${getCompareMethodText(chain[1].compareMethod)}:`}
|
||
</div>
|
||
<div className={`p-1 text-xs rounded-full min-w-[50px] text-center`}>
|
||
{res ? '通过' : '不通过'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
// 显示提示框,稍微向下偏移,便于鼠标移动到tooltip上
|
||
showTooltip(content, {
|
||
top: rect.top + rect.height/2,
|
||
left: rect.left
|
||
});
|
||
}}
|
||
onMouseLeave={hideTooltip}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
|
||
/**
|
||
* 渲染评查点有无判断,格式判断,逻辑判断,正则表达式的规则的样式
|
||
* @param otherRule 评查点规则数据
|
||
* @param reviewPoint 关联的评查点
|
||
* @returns 评查点有无判断,格式判断,逻辑判断,正则表达式的规则的样式
|
||
*/
|
||
const renderOtherRule = (otherRule: Record<string, unknown>, reviewPoint: ReviewPoint) => {
|
||
const fieldKey = otherRule.fieldKey as string;
|
||
const fieldValue = otherRule.fieldValue as {
|
||
type: Record<string, {
|
||
res: boolean;
|
||
page?: number | string;
|
||
value?: string;
|
||
char_positions?: CharPosition[];
|
||
}>;
|
||
};
|
||
|
||
// 获取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 (
|
||
<div className="flex flex-row gap-2 overflow-x-auto max-h-[300px]">
|
||
{Object.entries(fieldValue?.type || {}).map(([typeKey, typeValue]) => (
|
||
<div key={typeKey} className={`rounded-md flex flex-row items-center`}>
|
||
<div className="text-xs text-gray-600 pl-1 whitespace-nowrap">{getRuleTypeText(typeKey)}:</div>
|
||
<div className={`p-1 text-xs rounded-full min-w-[50px] text-center`}>
|
||
{typeValue.res ? '通过' : '不通过'}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 处理鼠标悬停事件
|
||
* 当鼠标悬停在状态指示器上时,计算提示框应该显示的位置并显示提示框
|
||
* @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 (
|
||
<button
|
||
className={`border border-gray rounded-md overflow-hidden mb-2 ${overallResult ? 'bg-[rgba(246,255,237,1)]' : 'bg-[rgba(255,251,230,1)]'} flex w-full text-left
|
||
hover:shadow-[0_0_10px_rgba(0,0,0,0.1)] ${overallResult ? 'hover:bg-[rgba(0,128,0,0.1)]' : 'hover:bg-[rgba(255,255,0,0.1)]'}`}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (mainTypeValue.page && typeof onReviewPointSelect === 'function') {
|
||
console.log("点击了其他评查点", mainTypeValue)
|
||
onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), mainTypeValue.char_positions, mainTypeValue.value);
|
||
// onReviewPointSelect(reviewPoint.id, undefined, mainTypeValue.char_positions, mainTypeValue.value);
|
||
}else if(reviewPoint.contentPage && reviewPoint.contentPage[fieldKey]){
|
||
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[fieldKey]), mainTypeValue.char_positions, mainTypeValue.value);
|
||
}else{
|
||
toastService.error(`没有找到${fieldKey}对应的索引内容`);
|
||
}
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
if (mainTypeValue.page && typeof onReviewPointSelect === 'function') {
|
||
onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), mainTypeValue.char_positions, mainTypeValue.value);
|
||
}else{
|
||
toastService.error(`没有找到${fieldKey}对应的索引内容`);
|
||
}
|
||
}
|
||
}}
|
||
type="button"
|
||
aria-label={`查看${fieldKey}内容详情`}
|
||
>
|
||
<div className="p-1 flex-1">
|
||
{/* 字段名称 */}
|
||
<div className="text-xs text-gray-500 mb-1">
|
||
{fieldKey}
|
||
{!mainTypeValue.page && (reviewPoint.contentPage && !reviewPoint.contentPage[fieldKey]) && (
|
||
<i className="ri-information-line text-red-500 text-xs ml-1" title="没有找到对应的文书内容"></i>
|
||
)}
|
||
{/* 缺失显示 */}
|
||
{mainTypeValue.res === false && !mainTypeValue.value && (
|
||
<span className="ml-2 text-xs text-yellow-500">
|
||
缺失
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* 主要值显示 */}
|
||
{mainTypeValue.value && (
|
||
<ReactTableTooltip content={mainTypeValue.value} />
|
||
)}
|
||
</div>
|
||
{/* 状态指示器 - 绑定鼠标事件用于显示/隐藏提示框 */}
|
||
<div
|
||
className="w-8 flex items-center justify-center rounded-r-md"
|
||
onMouseEnter={handleMouseEnter} // 鼠标进入时显示提示框
|
||
onMouseLeave={hideTooltip} // 鼠标离开时隐藏提示框
|
||
>
|
||
{overallResult ? (
|
||
<i className="ri-check-line text-success text-base hover:text-green-800" ></i>
|
||
) : (
|
||
<i className="ri-alert-line text-warning text-base hover:text-yellow-800" ></i>
|
||
)}
|
||
</div>
|
||
</button>
|
||
);
|
||
};
|
||
|
||
|
||
|
||
/**
|
||
* 渲染评查点大模型判断的规则的样式
|
||
*
|
||
* 该函数处理AI模型评估的结果展示,包括:
|
||
* 1. 从规则配置中提取字段和评估结果
|
||
* 2. 为每个字段创建可点击的UI元素,显示内容和评估状态
|
||
* 3. 展示模型的评估消息
|
||
* 4. 处理字段点击导航到相应页面的逻辑
|
||
*
|
||
* @param aiRule 评查点大模型判断的规则对象
|
||
* @param reviewPoint 关联的评查点对象
|
||
* @returns React组件,用于显示AI模型评估结果
|
||
*/
|
||
const renderModelRule = (aiRule: Record<string, unknown>, reviewPoint: ReviewPoint) => {
|
||
|
||
// 从aiRule中提取配置信息
|
||
const config = aiRule.config as {
|
||
model?: string;
|
||
fields?: Record<string, {
|
||
page: number | string;
|
||
value: string;
|
||
char_positions?: CharPosition[];
|
||
res?: boolean;
|
||
}>;
|
||
ai_suggestion?: {
|
||
summary?: string;
|
||
analysis?: {
|
||
failure_reason?: string;
|
||
solution_approach?: string;
|
||
rule_understanding?: string;
|
||
};
|
||
suggestions?: Record<string, {
|
||
reason: string;
|
||
source: {
|
||
page: number | null;
|
||
type: string;
|
||
field: string | null;
|
||
};
|
||
priority: string;
|
||
confidence: number;
|
||
suggested_value: string | null;
|
||
}>;
|
||
generated_at?: string;
|
||
};
|
||
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) => {
|
||
// 优先使用后端传入的 per-field res,fallback 到 value 非空判定
|
||
const res = value.res !== undefined && value.res !== null ? value.res : value.value.trim() !== '';
|
||
fieldElements.push(
|
||
<button
|
||
key={`field-${index}`}
|
||
className={`border border-gray w-full
|
||
rounded-md overflow-hidden mb-2 ${res ? 'bg-[rgba(246,255,237,1)]' : 'bg-[rgba(255,251,230,1)]'} flex
|
||
hover:shadow-[0_0_10px_rgba(0,0,0,0.1)] ${res ? 'hover:bg-[rgba(0,128,0,0.1)]' : 'hover:bg-[rgba(255,255,0,0.1)]'}`}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (value.page && typeof onReviewPointSelect === 'function') {
|
||
console.log("点击了大模型的评查点", value.char_positions, value)
|
||
onReviewPointSelect(reviewPoint.id, Number(value.page), value.char_positions, value.value);
|
||
}else if(reviewPoint.contentPage && reviewPoint.contentPage[key]){
|
||
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[key]), value.char_positions,value.value);
|
||
}else{
|
||
toastService.error(`没有找到${key}对应的索引内容`);
|
||
}
|
||
}}
|
||
onKeyDown={(e) => {
|
||
// 键盘导航支持
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
if (value.page && typeof onReviewPointSelect === 'function') {
|
||
onReviewPointSelect(reviewPoint.id, Number(value.page), value.char_positions, value.value);
|
||
}else if(reviewPoint.contentPage && reviewPoint.contentPage[key]){
|
||
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[key]), value.char_positions,value.value);
|
||
}else{
|
||
toastService.error(`没有找到${key}对应的索引内容`);
|
||
}
|
||
}
|
||
}}
|
||
type="button"
|
||
aria-label={`查看${key}内容详情`}
|
||
>
|
||
<div className="p-1 flex-1 text-left">
|
||
{/* 字段名称 */}
|
||
<div className="text-xs text-left text-gray-500 mb-1">
|
||
{key}
|
||
{/* 没有抽取到目录内容,page和value都为空 */}
|
||
{!value.page && (reviewPoint.contentPage && !reviewPoint.contentPage[key]) && (
|
||
<i className="ri-information-line text-red-500 text-xs ml-1" title="没有找到对应的文书内容"></i>
|
||
)}
|
||
{/* 缺失显示:仅在无值时显示 */}
|
||
{!res && !value.value && (
|
||
<span className="ml-2 text-xs text-yellow-500">
|
||
缺失
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* 主要值显示:有值就显示,不受res影响 */}
|
||
{value.value && (
|
||
<ReactTableTooltip content={value.value} />
|
||
)}
|
||
</div>
|
||
<div className={`w-8 flex items-center justify-center rounded-r-md group relative`}>
|
||
{res ? (
|
||
<i className="ri-check-line text-success text-base hover:text-green-800" ></i>
|
||
) : (
|
||
<i className="ri-alert-line text-warning text-base hover:text-yellow-800" ></i>
|
||
)}
|
||
{/* 使用鼠标事件处理悬停提示 */}
|
||
<div
|
||
className="w-full h-full absolute top-0 left-0"
|
||
onMouseEnter={(e) => {
|
||
// 获取元素位置信息
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
// 创建提示框内容
|
||
const content = (
|
||
<div className="flex flex-row gap-2 overflow-x-auto">
|
||
<div className={`rounded-md flex flex-row items-center`}>
|
||
<div className="text-xs text-gray-600 pl-1 whitespace-nowrap">大模型判断:</div>
|
||
<div className={`p-1 text-xs rounded-full min-w-[50px] text-center`}>
|
||
{res ? '通过' : '不通过'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
// 显示提示框,稍微向下偏移,便于鼠标移动到tooltip上
|
||
showTooltip(content, {
|
||
top: rect.top + rect.height/2,
|
||
left: rect.left
|
||
});
|
||
}}
|
||
onMouseLeave={hideTooltip}
|
||
/>
|
||
</div>
|
||
</button>
|
||
);
|
||
});
|
||
}
|
||
|
||
// 渲染AI模型返回的评估消息
|
||
if (config.message) {
|
||
// 检查message是否为对象,如果是则转换为字符串
|
||
const messageContent = typeof config.message === 'object'
|
||
? JSON.stringify(config.message)
|
||
: String(config.message);
|
||
|
||
// 添加模型评估消息区域,使用蓝色背景突出显示
|
||
fieldElements.push(
|
||
<div key="message" className="p-2 bg-blue-50 rounded border border-blue-200 text-xs mb-3 select-text">
|
||
<div className="flex flex-row items-center">
|
||
<i className="ri-robot-2-line text-blue-500 mr-2"></i>
|
||
<p className="text-xs text-gray-600 select-text mb-0">{messageContent}</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 渲染AI建议(ai_suggestion)
|
||
if (config.ai_suggestion?.suggestions && Object.keys(config.ai_suggestion.suggestions).length > 0) {
|
||
// 判断是否为PDF文档(禁用替换按钮)
|
||
fileFormat = fileFormat?.replace(/\./g,'')
|
||
const isPDF = fileFormat?.toUpperCase() === 'PDF';
|
||
|
||
// 遍历suggestions对象的key-value对
|
||
Object.entries(config.ai_suggestion.suggestions).forEach(([key, suggestionValue], index) => {
|
||
// 检查建议值是否存在(null 或有值都要渲染)
|
||
const hasSuggestedValue = suggestionValue.suggested_value !== null && suggestionValue.suggested_value.trim() !== '';
|
||
const isReplaceDisabled = !hasSuggestedValue || isPDF;
|
||
|
||
fieldElements.push(
|
||
<div key={`ai-suggestion-${index}`} className="mb-3">
|
||
{/* 字段名称标签 */}
|
||
<div className="text-xs text-gray-600 mb-2 font-medium">
|
||
<i className="ri-lightbulb-line text-yellow-500 mr-1"></i>
|
||
AI修改建议 - {key}
|
||
</div>
|
||
|
||
{/* 原因说明 */}
|
||
<div className="mb-2 p-2 bg-amber-50 rounded border border-amber-200 text-xs text-gray-700">
|
||
<div className="flex items-center">
|
||
<i className="ri-information-line text-amber-600 mr-1 mt-0.5"></i>
|
||
<div>
|
||
<span>{suggestionValue.reason}</span>
|
||
{suggestionValue.source.page !== null && (
|
||
<span className="ml-2 text-gray-500">
|
||
(来源页码: {suggestionValue.source.page})
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 建议内容和替换按钮 */}
|
||
<div className="flex gap-2 items-center">
|
||
{/* 文本输入框 */}
|
||
<textarea
|
||
value={suggestionValue.suggested_value || ''}
|
||
readOnly
|
||
disabled={!hasSuggestedValue}
|
||
rows={Math.min(Math.max(Math.ceil((suggestionValue.suggested_value || '').length / 30), 1), 5)}
|
||
className={`flex-1 p-2 border rounded text-xs resize-none overflow-y-auto focus:outline-none ${
|
||
hasSuggestedValue
|
||
? 'border-gray-200 bg-gray-50 text-gray-700'
|
||
: 'border-gray-200 bg-gray-100 text-gray-400'
|
||
}`}
|
||
aria-label={`${key}的AI建议内容`}
|
||
placeholder={!hasSuggestedValue ? '暂无建议值' : ''}
|
||
/>
|
||
|
||
{/* 意见替换按钮 */}
|
||
{ !isPDF &&
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (!isReplaceDisabled && onAiSuggestionReplace && config.fields) {
|
||
// 从 config.fields[key] 中获取对应的字段信息
|
||
const fieldData = config.fields[key];
|
||
if (fieldData) {
|
||
// 调用回调函数,传递搜索文本(原文)、替换文本(AI建议)和页码
|
||
onAiSuggestionReplace(
|
||
fieldData.value || '', // 搜索文本(使用 suggestions 的 key对应的config中的key的value值)
|
||
suggestionValue.suggested_value || '', // 替换文本(AI建议的 suggested_value)
|
||
Number(fieldData.page) || 1 // 页码
|
||
);
|
||
} else {
|
||
toastService.error(`未找到字段 ${key} 的原始数据`);
|
||
}
|
||
}
|
||
}}
|
||
disabled={isReplaceDisabled}
|
||
className={`px-3 py-2 text-xs rounded whitespace-nowrap transition-colors
|
||
${isReplaceDisabled
|
||
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||
: 'bg-primary text-white hover:bg-primary-hover active:bg-primary'
|
||
}`}
|
||
title={
|
||
isPDF
|
||
? 'PDF文档不支持替换'
|
||
: !hasSuggestedValue
|
||
? '暂无建议值,无法替换'
|
||
: '点击执行一键替换'
|
||
}
|
||
aria-label={`替换${key}的内容`}
|
||
>
|
||
<i className="ri-exchange-line mr-1"></i>
|
||
替换
|
||
</button>
|
||
}
|
||
</div>
|
||
</div>
|
||
);
|
||
});
|
||
}
|
||
|
||
// 返回包含所有元素的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;
|
||
char_positions?: CharPosition[];
|
||
type: Record<string, boolean>;
|
||
}
|
||
|
||
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<string, { page: number; value: string; char_positions?: CharPosition[] }>;
|
||
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<string, { page: string | number; value: string; char_positions?: CharPosition[] }>;
|
||
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<string, { page: number | string; value: string; char_positions?: CharPosition[] }>;
|
||
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<string, { page: number | string; value: string; char_positions?: CharPosition[] }>;
|
||
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<string, {
|
||
res: boolean;
|
||
page?: number | string;
|
||
value?: string;
|
||
char_positions?: CharPosition[];
|
||
}>;
|
||
};
|
||
}> = [];
|
||
|
||
// 使用对象存储相同fieldKey的项,便于快速查找和合并
|
||
const fieldKeyMap: Record<string, {
|
||
fieldKey: string;
|
||
fieldValue: {
|
||
type: Record<string, {
|
||
res: boolean;
|
||
page?: number | string;
|
||
value?: string;
|
||
char_positions?: CharPosition[];
|
||
}>;
|
||
};
|
||
}> = {};
|
||
|
||
// 第一步:按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;
|
||
const char_positions = fieldValue.char_positions;
|
||
|
||
// 如果是第一次遇到这个fieldKey,创建新条目
|
||
if (!fieldKeyMap[fieldKey]) {
|
||
// 创建新的结构
|
||
fieldKeyMap[fieldKey] = {
|
||
fieldKey,
|
||
fieldValue: {
|
||
type: {}
|
||
}
|
||
};
|
||
}
|
||
|
||
// 将类型信息添加到type对象中,允许一个字段有多种规则类型的结果
|
||
fieldKeyMap[fieldKey].fieldValue.type[typeKey] = {
|
||
res: typeValue,
|
||
page,
|
||
value,
|
||
char_positions
|
||
};
|
||
});
|
||
|
||
// 将合并后的对象转换为数组
|
||
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);
|
||
|
||
const handleManualReviewNotesChange = (reviewPointId: string, text: string) => {
|
||
setManualReviewNotes(prev => ({
|
||
...prev,
|
||
[reviewPointId]: text
|
||
}));
|
||
};
|
||
|
||
// 如果当前评查点不处于编辑状态 TODO delete
|
||
if (editingReviewPoint !== reviewPoint.id) {
|
||
|
||
// 根据result和status决定渲染哪种样式
|
||
if (reviewPoint.result === true) {
|
||
// 已通过的评查点只显示基本信息和人工审核注释
|
||
// 处理 result=true 且 postAction=manual 的情况
|
||
if (reviewPoint.postAction === 'manual') {
|
||
const note = manualReviewNotes[reviewPoint.id] || '';
|
||
|
||
// 处理重新审核意见的输入
|
||
const handleNoteChange = (reviewPointId: string, text: string) => {
|
||
setManualReviewNotes(prev => ({
|
||
...prev,
|
||
[reviewPointId]: text
|
||
}));
|
||
};
|
||
|
||
return (
|
||
<>
|
||
{checkContentPage(reviewPoint).pageIndex === 0 && (
|
||
<p className="text-xs text-red-500 select-text text-left mb-1">该评查点无法找到索引内容,无法自动定位到对应页面。</p>
|
||
)}
|
||
|
||
<div className="mt-2">
|
||
{/* {reviewPoint.suggestion && (
|
||
<div className="p-2 bg-blue-50 rounded border border-blue-200 text-xs mb-3 select-text">
|
||
<div className="flex items-start">
|
||
<i className="ri-information-line text-blue-500 mr-2 mt-0.5"></i>
|
||
<p className="text-xs text-gray-600 select-text">{reviewPoint.suggestion}</p>
|
||
</div>
|
||
</div>
|
||
)} */}
|
||
|
||
{/* 评查点内容显示区域 */}
|
||
{reviewPoint.content && Object.entries(reviewPoint.content).length > 0 && (
|
||
<div className="bg-white rounded border-gray-200 text-xs mb-3 select-text">
|
||
{/* 修改评查结果的结构之后,显示新的结构 */}
|
||
{renderContent(reviewPoint, mergedRules)}
|
||
</div>
|
||
)}
|
||
|
||
|
||
|
||
{/* 额外的文本输入框区域 */}
|
||
<div className="mb-3">
|
||
<textarea
|
||
id={`manual-review-${reviewPoint.id}`}
|
||
className={`w-full p-2 border rounded text-xs min-h-[80px] focus:outline-none ${reviewPoint.editAuditStatus !== 0 ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} focus:border-[#00684a] focus:shadow-[0_0_0_2px_rgba(0,104,74,0.2)]`}
|
||
placeholder="请输入审核意见..."
|
||
value={manualReviewNotes[reviewPoint.id] || ''}
|
||
onChange={(e) => handleNoteChange(reviewPoint.id, e.target.value)}
|
||
disabled={reviewPoint.editAuditStatus !== 0}
|
||
></textarea>
|
||
</div>
|
||
|
||
<div className="flex justify-end">
|
||
{reviewPoint.editAuditStatus === 0 ? (
|
||
<div className="w-full flex justify-end gap-2">
|
||
<button
|
||
className="bg-[#1890ff] hover:bg-blue-600 text-sm text-white py-1 px-2 rounded-md flex items-center justify-center"
|
||
onClick={() => handleReviewAction(reviewPoint.id, reviewPoint.editAuditStatusId, 'approve', note)}
|
||
>
|
||
<i className="ri-check-line mr-1"></i> 通过
|
||
</button>
|
||
<button
|
||
className="bg-[#f5222d] hover:bg-red-600 text-sm text-white py-1 px-2 rounded-md flex items-center justify-center"
|
||
onClick={() => handleReviewAction(reviewPoint.id, reviewPoint.editAuditStatusId, 'reject', note)}
|
||
>
|
||
<i className="ri-close-line mr-1"></i> 不通过
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
className="bg-purple-600 hover:bg-purple-700 text-sm text-white py-1 px-2 rounded-md flex items-center justify-center"
|
||
onClick={() => handleReviewAction(reviewPoint.id, reviewPoint.editAuditStatusId, 'review', '')}
|
||
>
|
||
<i className="ri-refresh-line mr-1"></i> 重新审核
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// 处理 result=true 且 postAction!=manual 的情况
|
||
return (
|
||
<>
|
||
{checkContentPage(reviewPoint).pageIndex === 0 && (
|
||
<p className="text-xs text-red-500 select-text text-left mb-1">该评查点无法找到索引内容,无法自动定位到对应页面。</p>
|
||
)}
|
||
{/* 评查点内容显示区域 */}
|
||
{reviewPoint.content && Object.entries(reviewPoint.content).length > 0 && (
|
||
<div className="bg-white rounded border-gray-200 text-xs mb-3 select-text">
|
||
{/* 修改评查结果的结构之后,显示新的结构 */}
|
||
{renderContent(reviewPoint, mergedRules)}
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
|
||
{/* 没有索引内容提示 */}
|
||
{checkContentPage(reviewPoint).pageIndex === 0 && (
|
||
<p className="text-xs text-red-500 select-text text-left mb-1">该评查点无法找到索引内容,无法自动定位到对应页面。</p>
|
||
)}
|
||
|
||
{/* 建议内容显示区域 */}
|
||
{reviewPoint.suggestion && (
|
||
<div className="p-2 bg-blue-50 rounded border border-blue-200 mb-3 text-xs select-text">
|
||
<div className="flex items-center flex-row">
|
||
<i className="ri-information-line text-blue-500 mr-2 mt-0.5"></i>
|
||
<p className="text-xs text-gray-600 select-text text-left mb-0">{reviewPoint.suggestion}</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
|
||
{/* 法律依据内容 */}
|
||
{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)) && (
|
||
<div className="bg-[#f0f9ff] border border-[#bae0ff] rounded p-3 mb-3 select-text">
|
||
{reviewPoint.legalBasis.name && (
|
||
<div className="text-[#0958d9] font-medium text-sm mb-2">{reviewPoint.legalBasis.name}</div>
|
||
)}
|
||
{reviewPoint.legalBasis.articles && Array.isArray(reviewPoint.legalBasis.articles) && reviewPoint.legalBasis.articles.length > 0 && (
|
||
<div className="flex flex-wrap gap-2 mb-2">
|
||
{reviewPoint.legalBasis.articles.map((item, index) => (
|
||
<span key={index} className="bg-[#e6f4ff] border border-[#91caff] rounded-full px-2 py-0.5 text-xs text-[#0958d9]">
|
||
{typeof item === 'string' ? item :
|
||
typeof item === 'object' && item !== null ?
|
||
(item.name || item.content || JSON.stringify(item)) :
|
||
String(item)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
{reviewPoint.legalBasis.content && (
|
||
<div className="text-[13px] text-[#434343] leading-relaxed border-l-[3px] border-[#bae0ff] pl-2 mt-2">{reviewPoint.legalBasis.content}</div>
|
||
)}
|
||
</div>
|
||
)
|
||
)}
|
||
|
||
|
||
{reviewPoint.content !== null && Object.keys(reviewPoint.content).length > 0 && (
|
||
<>
|
||
{/* 内容显示区域 */}
|
||
<div className="bg-white rounded border-gray-200 text-xs mb-3 select-text">
|
||
<div>
|
||
{/* 修改评查结果的结构之后,显示新的结构 */}
|
||
{renderContent(reviewPoint, mergedRules)}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 建议修改区域 */}
|
||
{/* {((reviewPoint.postAction === 'manual') || (reviewPoint.content !== null && Object.keys(reviewPoint.content).length > 0)) && ( */}
|
||
{(reviewPoint.postAction === 'manual') && (
|
||
<div className="mb-2">
|
||
<div className="flex justify-between items-center mb-2">
|
||
<span className="text-gray-700 text-[0.8rem]">{reviewPoint.postAction === 'manual' ? "审核意见:" : "建议修改为:"}</span>
|
||
{/* <span className="text-green-500">符合规范</span> */}
|
||
</div>
|
||
<textarea
|
||
value={manualReviewNotes[reviewPoint.id] || ''}
|
||
placeholder={reviewPoint.postAction === 'manual' ? "请输入审核意见..." : "请输入建议修改内容..."}
|
||
onChange={(e) => handleManualReviewNotesChange(reviewPoint.id, e.target.value)}
|
||
disabled={reviewPoint.editAuditStatus !== 0}
|
||
className={`text-xs w-full p-2 border rounded ${reviewPoint.editAuditStatus !== 0 ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} min-h-[100px] focus:outline-none focus:border-[#00684a] focus:shadow-[0_0_0_2px_rgba(0,104,74,0.2)]`}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 操作按钮区域 */}
|
||
{/* {((reviewPoint.postAction === 'manual') || (reviewPoint.content !== null && Object.keys(reviewPoint.content).length > 0)) && ( */}
|
||
{(reviewPoint.postAction === 'manual') && (
|
||
<div className="flex space-x-2 mt-2">
|
||
{/* 一键替换按钮 - 只有非人工审核的点或未通过的人工审核点才显示 */}
|
||
{(reviewPoint.postAction !== 'manual') && (
|
||
<button
|
||
className="replace-action flex-1 justify-center"
|
||
onClick={() => handleReplace(reviewPoint.id)}
|
||
>
|
||
<i className="ri-replace-line"></i> 一键替换
|
||
</button>
|
||
)}
|
||
|
||
{/* 人工审核按钮 */}
|
||
{reviewPoint.editAuditStatus === 0 ? (
|
||
<div className="w-full flex justify-end gap-2">
|
||
<button
|
||
className="bg-[#1890ff] hover:bg-blue-600 text-white py-1 px-2 rounded-md text-sm"
|
||
onClick={() => {
|
||
const note = manualReviewNotes[reviewPoint.id] || '';
|
||
handleReviewAction(reviewPoint.id, reviewPoint.editAuditStatusId, 'approve', note);
|
||
}}
|
||
>
|
||
<i className="ri-check-line mr-1"></i> 通过
|
||
</button>
|
||
<button
|
||
className="bg-[#f5222d] hover:bg-red-600 text-white py-1 px-2 rounded-md text-sm"
|
||
onClick={() => {
|
||
const note = manualReviewNotes[reviewPoint.id] || '';
|
||
handleReviewAction(reviewPoint.id, reviewPoint.editAuditStatusId, 'reject', note);
|
||
}}
|
||
>
|
||
<i className="ri-close-line mr-1"></i> 不通过
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="w-full flex justify-end">
|
||
<button
|
||
className="bg-purple-600 hover:bg-purple-700 text-white py-1 px-2 rounded-md text-sm"
|
||
onClick={() => {
|
||
// 重新审核时不传递message
|
||
handleReviewAction(reviewPoint.id, reviewPoint.editAuditStatusId, 'review', '');
|
||
}}
|
||
>
|
||
<i className="ri-refresh-line mr-1"></i> 重新审核
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
};
|
||
|
||
/**
|
||
* 渲染无匹配结果提示
|
||
* 当过滤后没有评查点时显示
|
||
*/
|
||
const renderEmptyState = () => {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center py-8 px-4 text-center text-gray-500">
|
||
<i className="ri-search-line text-3xl mb-2"></i>
|
||
<p className="text-sm mb-1">没有找到匹配的评查点</p>
|
||
<p className="text-xs">请尝试不同的搜索词或清除筛选条件</p>
|
||
{(searchText || statusFilter) && (
|
||
<button
|
||
className="mt-3 text-xs text-primary underline"
|
||
onClick={() => {
|
||
setSearchText('');
|
||
setStatusFilter(null);
|
||
}}
|
||
>
|
||
清除所有筛选
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 处理评查点点击事件
|
||
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) >= -1) {
|
||
// 返回第一个找到的有效页码,以及对应的key
|
||
return {
|
||
pageIndex: parseInt(reviewPoint.contentPage[key] as string),
|
||
key,
|
||
id: reviewPoint.id
|
||
};
|
||
}
|
||
}
|
||
|
||
// 如果遍历完所有key都没找到有效页码,返回默认值
|
||
return result;
|
||
};
|
||
|
||
// 组件主渲染函数
|
||
return (
|
||
<div className="review-points-panel select-text">
|
||
<TooltipPortal />
|
||
{/* 面板头部 */}
|
||
<div className="review-panel-header py-2 px-4 flex items-center">
|
||
<i className="ri-file-list-check-line text-primary mr-2"></i>
|
||
<span className="font-medium text-primary">评查结果</span>
|
||
</div>
|
||
|
||
{/* 评查统计 */}
|
||
{renderStatistics()}
|
||
|
||
{/* 搜索框 */}
|
||
{renderSearchBar()}
|
||
|
||
{/* 评查点列表 */}
|
||
<div className="review-points-list">
|
||
{/* GraphRAG Scored 模式渲染 */}
|
||
{flowType === 'graphrag' && scoredResults && scoredResults.length > 0 ? (
|
||
scoredResults.map(result => (
|
||
<ScoredResultCard key={result.evaluation_point_id} result={result} />
|
||
))
|
||
) : filteredReviewPoints.length > 0 ? (
|
||
filteredReviewPoints.map(reviewPoint => (
|
||
<div
|
||
key={reviewPoint.id}
|
||
className={`rounded-md review-point-item ${activeReviewPointResultId === reviewPoint.id ? 'active border-l-4 !border-l-[rgba(0,104,74,1)] shadow-md' : 'border-l-4 border-l-transparent'}
|
||
transition-all duration-300 ease-in-out shadow-sm
|
||
hover:shadow-lg hover:border-l-4 hover:border-l-[rgba(0,104,74,0.3)]
|
||
hover:bg-[rgba(0,0,0,0.02)] my-2`}
|
||
role="button"
|
||
tabIndex={0}
|
||
style={{ userSelect: 'text' }}
|
||
onClick={() => {
|
||
console.log('reviewPoint', reviewPoint);
|
||
handleReviewPointClick(reviewPoint.id);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
handleReviewPointClick(reviewPoint.id);
|
||
}
|
||
}}
|
||
>
|
||
{/* 评查点标题和状态 */}
|
||
{/* 评查点名称 pointName*/}
|
||
<div className="flex justify-between items-center mb-2">
|
||
{/* <div className='flex flex-col'> */}
|
||
<div className="flex items-center gap-2 max-w-[75%]">
|
||
{reviewPoint.pointCode ? (
|
||
<span className="text-xs font-mono bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded border border-blue-200 flex-shrink-0">{reviewPoint.pointCode}</span>
|
||
) : (
|
||
<span className="text-xs font-mono bg-gray-50 text-gray-500 px-1.5 py-0.5 rounded border border-gray-200 flex-shrink-0">#{reviewPoint.pointId || reviewPoint.id}</span>
|
||
)}
|
||
<div className="review-point-title text-left text-blue-500 break-all">{reviewPoint.pointName}</div>
|
||
{ reviewPoint.pointName === '签署乙方详细信息校验' && (
|
||
<button
|
||
className="enterprise-info-btn"
|
||
style={{
|
||
padding: '2px 8px',
|
||
fontSize: '12px',
|
||
borderRadius: '4px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '4px',
|
||
flexShrink: 0,
|
||
transition: 'all 0.2s',
|
||
border: 'none',
|
||
cursor: reviewPoint.content?.['合同主体信息-乙方名称']?.value ? 'pointer' : 'not-allowed',
|
||
backgroundColor: reviewPoint.content?.['合同主体信息-乙方名称']?.value ? '#00684a' : '#e5e7eb',
|
||
color: reviewPoint.content?.['合同主体信息-乙方名称']?.value ? '#ffffff' : '#9ca3af',
|
||
}}
|
||
disabled={!reviewPoint.content?.['合同主体信息-乙方名称']?.value}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
const companyNameValue = reviewPoint.content?.['合同主体信息-乙方名称']?.value;
|
||
// console.log('companyNameValue', companyNameValue);
|
||
const companyName = typeof companyNameValue === 'string' ? companyNameValue : String(companyNameValue || '');
|
||
if (companyName) {
|
||
handleCorporateInfoClick(companyName);
|
||
}
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (reviewPoint.content?.['合同主体信息-乙方名称']?.value) {
|
||
e.currentTarget.style.backgroundColor = '#005a3f';
|
||
}
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (reviewPoint.content?.['合同主体信息-乙方名称']?.value) {
|
||
e.currentTarget.style.backgroundColor = '#00684a';
|
||
}
|
||
}}
|
||
>
|
||
<i className="ri-eye-line"></i>
|
||
乙方企业信息
|
||
</button>
|
||
)}
|
||
{ reviewPoint.pointName === '签署甲方详细信息校验' && (
|
||
<button
|
||
className="enterprise-info-btn"
|
||
style={{
|
||
padding: '2px 8px',
|
||
fontSize: '12px',
|
||
borderRadius: '4px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '4px',
|
||
flexShrink: 0,
|
||
transition: 'all 0.2s',
|
||
border: 'none',
|
||
cursor: reviewPoint.content?.['合同主体信息-甲方名称']?.value ? 'pointer' : 'not-allowed',
|
||
backgroundColor: reviewPoint.content?.['合同主体信息-甲方名称']?.value ? '#00684a' : '#e5e7eb',
|
||
color: reviewPoint.content?.['合同主体信息-甲方名称']?.value ? '#ffffff' : '#9ca3af',
|
||
}}
|
||
disabled={!reviewPoint.content?.['合同主体信息-甲方名称']?.value}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
const companyNameValue = reviewPoint.content?.['合同主体信息-甲方名称']?.value;
|
||
const companyName = typeof companyNameValue === 'string' ? companyNameValue : String(companyNameValue || '');
|
||
if (companyName) {
|
||
handleCorporateInfoClick(companyName);
|
||
}
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (reviewPoint.content?.['合同主体信息-甲方名称']?.value) {
|
||
e.currentTarget.style.backgroundColor = '#005a3f';
|
||
}
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (reviewPoint.content?.['合同主体信息-甲方名称']?.value) {
|
||
e.currentTarget.style.backgroundColor = '#00684a';
|
||
}
|
||
}}
|
||
>
|
||
<i className="ri-eye-line"></i>
|
||
甲方企业信息
|
||
</button>
|
||
)}
|
||
|
||
</div>
|
||
{/* <div className="review-point-header flex justify-between items-start">
|
||
<div className="flex-1 text-left min-w-[25%] font-medium text-[13px]">{reviewPoint.title}</div>
|
||
//评查点分组显示
|
||
<div className="review-point-location max-w-[40%] flex items-center">
|
||
<i className="ri-file-list-line mr-1 flex-shrink-0"></i>
|
||
<span
|
||
className="truncate block whitespace-nowrap overflow-hidden
|
||
hover:overflow-visible hover:text-clip hover:bg-white hover:shadow-md hover:z-10 hover:text-wrap px-1 rounded"
|
||
title={reviewPoint.groupName}
|
||
style={{ cursor: 'text', userSelect: 'all' }}
|
||
>
|
||
{reviewPoint.groupName}
|
||
</span>
|
||
</div>
|
||
</div> */}
|
||
{/* </div> */}
|
||
<div className="flex ml-2 flex-shrink-0 min-w-[15%]">
|
||
{renderStatusBadge(reviewPoint.status, reviewPoint.result,reviewPoint.title)}
|
||
{renderHumanReviewBadge(reviewPoint)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 评查点内容和操作 */}
|
||
{renderReviewPointContent(reviewPoint)}
|
||
</div>
|
||
))
|
||
) : (
|
||
renderEmptyState()
|
||
)}
|
||
</div>
|
||
|
||
{/* 企业信息模态框 */}
|
||
<CorporateInfoModal
|
||
visible={corporateModalVisible}
|
||
onClose={handleCloseCorporateModal}
|
||
companyName={corporateCompanyName}
|
||
businessInfo={corporateBusinessInfo}
|
||
dishonestyInfo={corporateDishonestyInfo}
|
||
businessLoading={corporateLoading}
|
||
dishonestyLoading={corporateLoading}
|
||
businessError={corporateError}
|
||
dishonestyError={corporateError}
|
||
updatedAt={corporateUpdatedAt}
|
||
onForceRefresh={handleCorporateForceRefresh}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|