Files
leaudit-platform-frontend/app/components/reviews/ReviewPointsList.tsx
T
TanWenyan d0124b0121 feat(ui): support markdown table rendering in evaluation field values
ReactTableTooltip now detects and renders markdown tables (| delimited)
in addition to existing tab-delimited tables. Table content is rendered
directly as an HTML table instead of showing raw markdown text.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 19:19:53 +08:00

2783 lines
110 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 评查点列表组件
*
* 功能概述:
* - 展示评查结果统计信息(总计、通过、警告、错误数量)
* - 提供评查点过滤功能(按状态和搜索文本)
* - 显示评查点详细信息(标题、状态、内容、建议修改等)
* - 支持评查点操作(一键替换、人工审核等)
*
* 组件结构:
* - 统计区域: 显示评查点数量统计
* - 搜索区域: 提供文本搜索功能
* - 评查点列表: 展示所有评查点
* - 评查点卡片: 展示单个评查点详情
* - 评查点头部: 显示标题和状态
* - 评查点内容: 显示当前内容和问题
* - 建议修改区域: 显示建议的修改内容
* - 操作按钮: 提供一键替换和人工审核功能
*/
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;
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;
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 表格:至少有 | 分隔符和分隔行 |---|
const isMdTable = content.includes('|') && /\|[-\s:]+\|/.test(content);
const isTableLike = isTabTable || isMdTable;
useEffect(() => {
const checkTextOverflow = () => {
const element = textRef.current;
if (element) {
// 如果是表格格式,总是显示tooltip;否则只在文本溢出时显示
setShowTooltip(isTableLike || element.scrollHeight > element.clientHeight);
}
};
// 预渲染内容并缓存
if (isMdTable) {
setRenderedContent(renderMarkdownTable(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());
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);
}
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>;
}
};
// 渲染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>;
}
};
// 表格内容直接渲染表格组件,非表格内容保持原有行为
if (isTableLike) {
return (
<div className="text-xs p-1 rounded cursor-text w-full text-left">
{renderedContent}
</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.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 resfallback 到 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%]">
<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>
);
}