Files
leaudit-platform-frontend/app/components/cross-checking/ReviewPointsList.tsx
T

2322 lines
88 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 { Modal } from '../ui/Modal';
import { submitCrossCheckingOpinion, type SubmitOpinionRequest } from '../../api/cross-checking/cross-file-result';
// 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 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;
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;
}
// 定义评分提案数据接口
interface ScoringProposal {
id: string | number;
evaluation_result_id: string | number;
proposer_id: string | number;
proposed_score: number;
reason: string;
status: string;
created_at: string;
updated_at: string;
document_id: string | number;
}
interface ReviewPointsListProps {
reviewPoints: ReviewPoint[];
statistics: Statistics;
activeReviewPointResultId: string | null;
onReviewPointSelect: (id: string, page?: number) => void;
onStatusChange?: (id: string, editAuditStatusId: string | number, status: string, message: string) => void;
scoringProposals?: ScoringProposal[];
}
/**
* 全局状态对象,存储当前活动的提示框信息
* 这种方式避免了复杂的状态提升或Context API的使用
*/
let activeTooltip = {
show: false, // 控制提示框是否显示
content: null as React.ReactNode, // 提示框内容(React节点)
position: { top: 0, left: 0 }, // 提示框在屏幕上的位置
ready: false // 新增:控制是否已准备好显示
};
/**
* 提示框Portal组件
*
* 使用React Portal将提示框渲染到document.body下,
* 这样可以确保提示框不受任何父元素overflow或z-index限制
*/
function TooltipPortal() {
// 使用本地状态保存提示框信息的副本
const [tooltip, setTooltip] = useState(activeTooltip);
useEffect(() => {
// 通过自定义事件机制监听全局tooltip状态更新
const updateTooltip = () => {
// 使用扩展运算符创建对象副本,确保状态更新被React检测到
setTooltip({...activeTooltip});
};
// 添加事件监听器
window.addEventListener('tooltip-update', updateTooltip);
// 组件卸载时清理事件监听器
return () => {
window.removeEventListener('tooltip-update', updateTooltip);
};
}, []);
// 如果不显示或没有内容,则不渲染任何东西
if (!tooltip.show || !tooltip.content) return null;
// 使用createPortal将提示框内容渲染到document.body
return createPortal(
<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 isTableLike = content.includes('\t') && content.includes('\n');
useEffect(() => {
const checkTextOverflow = () => {
const element = textRef.current;
if (element) {
// 如果是表格格式,总是显示tooltip;否则只在文本溢出时显示
setShowTooltip(isTableLike || element.scrollHeight > element.clientHeight);
}
};
// 预渲染内容并缓存
if (isTableLike) {
setRenderedContent(renderReactTable(content));
} else {
setRenderedContent(content);
}
requestAnimationFrame(checkTextOverflow);
window.addEventListener('resize', checkTextOverflow);
return () => {
window.removeEventListener('resize', checkTextOverflow);
};
}, [content, isTableLike]);
// 解析表格数据
const parseTableData = (text: string) => {
const rows = text.split('\n').map(row => row.split('\t'));
return rows;
};
// 渲染React表格
const renderReactTable = (text: string) => {
try {
const tableData = parseTableData(text);
const hasHeader = tableData.length > 0;
return (
<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>;
}
};
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,
scoringProposals = []
}: ReviewPointsListProps) {
// 状态管理
const [searchText, setSearchText] = useState(''); // 搜索文本
const [statusFilter, setStatusFilter] = useState<string | null>(null); // 状态过滤
const [evaluationResultIds, setEvaluationResultIds] = useState<number[]>([]); // 评分提案的evaluation_result_id
// 在组件中使用scoringProposals(这里只是简单使用以避免linter警告)
// 将来可以用于显示相关的评分提案信息
useEffect(() => {
if (scoringProposals && scoringProposals.length > 0) {
console.log('收到评分提案数据:', scoringProposals.length, '个提案');
// 获取提案的evaluation_result_id
const evaluationResultIds = scoringProposals.map(proposal => Number(proposal.evaluation_result_id));
setEvaluationResultIds(evaluationResultIds);
// console.log('提案的evaluation_result_id:', evaluationResultIds);
}
}, [scoringProposals]);
// 提出意见模态框相关状态
const [isOpinionModalOpen, setIsOpinionModalOpen] = useState(false);
const [selectedReviewPoint, setSelectedReviewPoint] = useState<ReviewPoint | null>(null);
const [opinionForm, setOpinionForm] = useState({
// 评查点名称
auditPoint: '',
// 发现问题
foundIssue: '',
// 审查意见
auditOpinion: '',
// 扣分
deductionScore: 0
});
const [isSubmittingOpinion, setIsSubmittingOpinion] = useState(false);
// 存放评查点ID与有效页码的映射
const [effectivePages, setEffectivePages] = useState<Record<string, number>>({});
/**
* 打开提出意见模态框
*/
const handleOpenOpinionModal = (reviewPoint: ReviewPoint) => {
// 如果评分提案的evaluation_result_id包含当前评查点的id,则不打开模态框
if (evaluationResultIds.includes(Number(reviewPoint.id))) {
toastService.error('当前评查点已有意见提出项,可前往意见列表查看');
return;
}
setSelectedReviewPoint(reviewPoint);
setOpinionForm({
auditPoint: reviewPoint.pointName,
foundIssue: reviewPoint.result ? (reviewPoint.passMessage || '') : (reviewPoint.failMessage || ''),
auditOpinion: '',
deductionScore: 0
});
setIsOpinionModalOpen(true);
};
/**
* 关闭提出意见模态框
*/
const handleCloseOpinionModal = () => {
setIsOpinionModalOpen(false);
setSelectedReviewPoint(null);
setOpinionForm({
auditPoint: '',
foundIssue: '',
auditOpinion: '',
deductionScore: 0
});
};
/**
* 处理意见表单输入
*/
const handleOpinionFormChange = (field: string, value: string | number) => {
setOpinionForm(prev => ({
...prev,
[field]: value
}));
};
/**
* 提交意见
*/
const handleSubmitOpinion = async () => {
// 校验表单
if (!opinionForm.auditOpinion.trim()) {
toastService.error('请填写审查意见');
return;
}
if (opinionForm.deductionScore <= 0) {
toastService.error('扣分必须大于0');
return;
}
if (opinionForm.deductionScore > 100) {
toastService.error('扣分不能大于100分');
return;
}
if (!selectedReviewPoint) {
toastService.error('未选择评查点');
return;
}
setIsSubmittingOpinion(true);
try {
const opinionData: SubmitOpinionRequest = {
reviewPointResultId: selectedReviewPoint.id,
documentId: selectedReviewPoint.documentId || '',
auditPoint: opinionForm.auditPoint,
foundIssue: opinionForm.foundIssue,
auditOpinion: opinionForm.auditOpinion,
deductionScore: opinionForm.deductionScore
};
const response = await submitCrossCheckingOpinion(opinionData);
if (response.error) {
toastService.error(response.error);
return;
}
toastService.success('意见提交成功');
handleCloseOpinionModal();
} catch (error) {
console.error('提交意见失败:', error);
toastService.error('提交意见失败,请稍后重试');
} finally {
setIsSubmittingOpinion(false);
}
};
/**
* 过滤评查点
* 根据搜索文本和状态过滤条件筛选评查点
*/
const filteredReviewPoints = reviewPoints.filter(point => {
// 匹配搜索文本
const matchesSearch = searchText === '' ||
point.pointName.toLowerCase().includes(searchText.toLowerCase()) ||
point.title.toLowerCase().includes(searchText.toLowerCase()) ||
// point.groupName.toLowerCase().includes(searchText.toLowerCase()) ||
JSON.stringify(point.content).toLowerCase().includes(searchText.toLowerCase())
// 处理状态过滤
let matchesStatus = false;
if (statusFilter === null) {
// 未选择过滤条件时显示所有
matchesStatus = true;
} else if (statusFilter === 'success') {
// 过滤"通过"状态
matchesStatus = point.result === true;
} else if (statusFilter === 'warning') {
// 过滤"警告"状态
matchesStatus = point.result === false && (point.status === 'warning' || point.status === 'info');
} else if (statusFilter === 'error') {
// 过滤"错误"状态
matchesStatus = point.result === false && point.status === 'error';
}
// console.log('筛选point', point);
return matchesSearch && matchesStatus;
});
// console.log('筛选filteredReviewPoints', filteredReviewPoints);
/**
* 渲染评查统计信息
* 显示总计、通过、警告、错误数量
*/
const renderStatistics = () => {
// 确保传入的statistics存在,否则使用计算值
const statsToUse = statistics || {
total: reviewPoints.length,
success: 0,
warning: 0,
error: 0,
score: 0
};
// 计算各个状态的评查点数量
const successCount = reviewPoints.filter(
point => point.result === true || (point.result === undefined && point.status === 'success')
).length;
const warningCount = reviewPoints.filter(
point => point.result === false && (point.status === 'warning' || point.status === 'info')
).length;
const errorCount = reviewPoints.filter(
point => point.result === false && point.status === 'error'
).length;
// 如果没有计算值,则使用传入的统计值
const totalToShow = statsToUse.total === 0 ? reviewPoints.length : statsToUse.total;
const successToShow = successCount || statsToUse.success;
const warningToShow = warningCount || statsToUse.warning;
const errorToShow = errorCount || statsToUse.error;
return (
<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>
</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 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 }>;
targetField: Record<string, { page: number; value: string }>;
res: boolean;
compareMethod?: string;
}>;
selectedFields?: string[]
} | undefined;
if (!config || !config.pairs || !Array.isArray(config.pairs) || config.pairs.length === 0) {
return null;
}
// 处理配对数据
const pairs = config.pairs;
// 获取第一个有效页码
if (reviewPoint.id && !effectivePages[reviewPoint.id]) {
for (const pair of pairs) {
// 检查sourceField中是否有有效页码
const sourceFieldKey = Object.keys(pair.sourceField)[0];
if (sourceFieldKey && pair.sourceField[sourceFieldKey].page &&
Number(pair.sourceField[sourceFieldKey].page) > 0) {
// 保存页码
setEffectivePages(prev => ({
...prev,
[reviewPoint.id || '']: Number(pair.sourceField[sourceFieldKey].page)
}));
break;
}
// 如果sourceField没有有效页码,检查targetField
const targetFieldKey = Object.keys(pair.targetField)[0];
if (targetFieldKey && pair.targetField[targetFieldKey].page &&
Number(pair.targetField[targetFieldKey].page) > 0) {
// 保存页码
setEffectivePages(prev => ({
...prev,
[reviewPoint.id || '']: Number(pair.targetField[targetFieldKey].page)
}));
break;
}
}
}
// 查找链条关系
const findChains = () => {
type ChainItem = {
field: string;
data: {
key: string;
page: number;
value: string
};
res: boolean;
compareMethod?: string;
};
const chains: Array<Array<ChainItem>> = [];
const visited = new Set<string>();
// 构建字段映射关系
const fieldMap = new Map<string, Array<{
targetField: string;
data: {
source: { key: string; page: number; value: string };
target: { key: string; page: number; value: string };
};
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));
break;
}
}
if (!hasPage) {
// toastService.error('没有找到有效的页码');
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
// 遍历chain找到第一个有效的page
for (const item of chain) {
if (item.data.page && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPoint.id, Number(item.data.page));
break;
}
}
}
}}
role="button"
tabIndex={0}
>
<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('currentitem-------', reviewPoint);
// 假设onReviewPointSelect在作用域内可用
const reviewPointId = reviewPoint.id as string;
if (reviewPointId && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPointId, Number(item.data.page));
}
}
else if(reviewPoint.contentPage && reviewPoint.contentPage[item.field]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[item.field]));
}
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();
if (chain[0].data.page) {
const reviewPointId = reviewPoint.id as string;
if (reviewPointId && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPointId, chain[0].data.page);
}
}
else if(reviewPoint.contentPage && reviewPoint.contentPage[chain[0].field]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[0].field]));
}
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) {
const reviewPointId = reviewPoint.id as string;
if (reviewPointId && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPointId, chain[1].data.page);
}
}
else if(reviewPoint.contentPage && reviewPoint.contentPage[chain[1].field]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[1].field]));
}
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;
}>;
};
// 获取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') {
onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page));
}else if(reviewPoint.contentPage && reviewPoint.contentPage[fieldKey]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[fieldKey]));
}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));
}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;
}>;
message?: string;
res?: boolean;
} | undefined;
// 如果评查点评查结果和规则的结果不一致,则不渲染,跳过
if(config?.res !== reviewPoint.result){
return null;
}
// 如果配置不存在,不渲染任何内容
if (!config) return null;
// 获取第一个有效页码
if (reviewPoint.id && !effectivePages[reviewPoint.id] && config.fields) {
for (const field of Object.values(config.fields || {})) {
if (field.page && Number(field.page) > 0) {
setEffectivePages(prev => ({
...prev,
[reviewPoint.id || '']: Number(field.page)
}));
break;
}
}
}
// 创建一个数组来存储需要渲染的JSX元素
const fieldElements: JSX.Element[] = [];
// 遍历fields,获取每个字段的值并生成对应的JSX元素
if (config.fields) {
Object.entries(config.fields).forEach(([key, value], index) => {
const res = value.value.trim() !== '';
fieldElements.push(
<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') {
onReviewPointSelect(reviewPoint.id, Number(value.page));
}else if(reviewPoint.contentPage && reviewPoint.contentPage[key]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[key]));
}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));
}else if(reviewPoint.contentPage && reviewPoint.contentPage[key]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[key]));
}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 && (
<span className="ml-2 text-xs text-yellow-500">
</span>
)}
</div>
{/* 主要值显示 */}
{res && (
<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>
);
}
// 返回包含所有元素的React片段
return <>{fieldElements}</>;
};
/**
* 过滤评查点中的规则,把type是exists、format、logic、regex的规则中重复的进行去重和合并
*
* 该函数的主要作用:
* 1. 从评查点的evaluatedPointResultsLog中提取特定类型的规则
* 2. 将相同字段(fieldKey)的不同规则类型结果合并到一起
* 3. 为UI渲染准备统一结构的数据
*
* 支持的规则类型:
* - exists: 有无判断规则
* - format: 格式判断规则
* - logic: 逻辑判断规则
* - regex: 正则表达式规则
*
* @param reviewPoint 评查点对象
* @returns 合并后的规则数组,每个元素包含字段名和各类规则的评估结果
*/
const filterOtherRule = (reviewPoint: ReviewPoint) => {
// 定义接口描述规则字段值的结构
interface RuleFieldValue {
page?: number | string;
value?: string;
type: Record<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 }>;
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 }>;
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 }>;
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 }>;
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;
}>;
};
}> = [];
// 使用对象存储相同fieldKey的项,便于快速查找和合并
const fieldKeyMap: Record<string, {
fieldKey: string;
fieldValue: {
type: Record<string, {
res: boolean;
page?: number | string;
value?: string;
}>;
};
}> = {};
// 第一步:按fieldKey分组并合并不同类型的规则结果
allRule.forEach(item => {
const fieldKey = item.fieldKey;
const fieldValue = item.fieldValue;
const typeKey = Object.keys(fieldValue.type)[0]; // 获取类型名称(exists/logic/regex/format
const typeValue = fieldValue.type[typeKey]; // 获取类型值(true/false
// 提取页码和值
const page = fieldValue.page;
const value = fieldValue.value;
// 如果是第一次遇到这个fieldKey,创建新条目
if (!fieldKeyMap[fieldKey]) {
// 创建新的结构
fieldKeyMap[fieldKey] = {
fieldKey,
fieldValue: {
type: {}
}
};
}
// 将类型信息添加到type对象中,允许一个字段有多种规则类型的结果
fieldKeyMap[fieldKey].fieldValue.type[typeKey] = {
res: typeValue,
page,
value
};
});
// 将合并后的对象转换为数组
for (const key in fieldKeyMap) {
mergedRules.push(fieldKeyMap[key]);
}
// 获取第一个有效页码
if (reviewPoint.id && !effectivePages[reviewPoint.id]) {
// 遍历合并后的规则数组,查找第一个有效页码
for (const rule of mergedRules) {
// 遍历字段类型对象
const typeEntries = Object.entries(rule.fieldValue.type);
// 遍历每种类型规则
for (const [, typeValue] of typeEntries) {
// 检查是否有有效页码
if (typeValue.page && Number(typeValue.page) > 0) {
// 找到有效页码,设置状态并跳出循环
setEffectivePages(prev => ({
...prev,
[reviewPoint.id || '']: Number(typeValue.page)
}));
// 使用break跳出当前循环
break;
}
}
// 如果已经找到有效页码,跳出外层循环
if (reviewPoint.id && effectivePages[reviewPoint.id]) {
break;
}
}
}
// 返回合并后的规则数组
return mergedRules;
};
/**
* 渲染评查点内容与建议
* @param reviewPoint 评查点
* @returns 评查点内容与建议组件
*/
const renderReviewPointContent = (reviewPoint: ReviewPoint) => {
const mergedRules = filterOtherRule(reviewPoint);
// console.log('mergedRules1-------', mergedRules);
// 根据result和status决定渲染哪种样式
if (reviewPoint.result === true) {
// 已通过的评查点只显示基本信息和人工审核注释
return (
<>
{checkContentPage(reviewPoint).pageIndex === 0 && (
<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="p-2 bg-white rounded border border-gray-200 text-xs mb-3 select-text">
<div className="flex justify-between items-center mb-1">
<span className="text-xs font-medium"></span>
</div>
{reviewPoint.legalBasis.name && (
<p className="text-xs text-left mb-1 select-text">{reviewPoint.legalBasis.name}</p>
)}
{reviewPoint.legalBasis.content && (
<p className="text-xs text-left mb-1 select-text"><span className="font-medium"></span>{reviewPoint.legalBasis.content}</p>
)}
{reviewPoint.legalBasis.articles && Array.isArray(reviewPoint.legalBasis.articles) && reviewPoint.legalBasis.articles.length > 0 && (
<div>
<p className="text-xs text-left font-medium mb-1"></p>
<ul className="list-disc pl-4 select-text">
{reviewPoint.legalBasis.articles.map((item, index) => (
<li key={index} className="text-xs text-left select-text">
{typeof item === 'string' ? item :
typeof item === 'object' && item !== null ?
(item.name ? `${item.name}: ${item.content || ''}` :
item.content || JSON.stringify(item)) :
String(item)}
</li>
))}
</ul>
</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>
</>
)}
</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) > 0) {
// 返回第一个找到的有效页码,以及对应的key
return {
pageIndex: parseInt(reviewPoint.contentPage[key] as string),
key,
id: reviewPoint.id
};
}
}
// 如果遍历完所有key都没找到有效页码,返回默认值
return result;
};
// 组件主渲染函数
return (
<>
<div className="relative">
{/* 悬浮的意见数量显示 - 固定在左侧 */}
<div className="absolute left-[-35px] top-16 z-10 group cursor-pointer">
{/* 默认状态:竖向排列,窄宽度 */}
<div className="flex flex-col items-center bg-blue-50 px-2 py-2 rounded-lg border border-blue-200 shadow-md transition-all duration-300 group-hover:scale-0 group-hover:opacity-0 origin-top-right">
<i className="ri-chat-1-line text-blue-600 text-base"></i>
<span className="text-base text-blue-600 font-bold leading-tight whitespace-nowrap">{scoringProposals.length}</span>
<span className="text-xs text-blue-500 leading-tight whitespace-wrap"></span>
<span className="text-xs text-blue-500 leading-tight whitespace-wrap"></span>
<span className="text-xs text-blue-500 leading-tight whitespace-wrap"></span>
</div>
{/* 悬浮状态:横向排列,显示图标,数字放大 */}
<div className="absolute top-0 right-0 opacity-0 scale-0 group-hover:opacity-100 group-hover:scale-100 flex items-center bg-blue-50 px-3 py-2 rounded-lg border border-blue-200 shadow-lg transition-all duration-300 origin-top-right">
<button className="flex items-center" aria-label="点击查看详情">
<div className="flex flex-col">
<i className="ri-chat-1-line text-blue-600 text-base"></i>
<span className="text-xl text-blue-600 font-bold">{scoringProposals.length}</span>
<span className="text-xs text-blue-500 leading-tight whitespace-wrap"></span>
<span className="text-xs text-blue-500 leading-tight whitespace-wrap"></span>
<span className="text-xs text-blue-500 leading-tight whitespace-wrap"></span>
</div>
</button>
</div>
</div>
<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">
{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="review-point-title text-left text-blue-500 max-w-[75%] break-all">{reviewPoint.pointName}</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%]">
{/* 提出意见按钮 */}
<div className="flex items-center">
<button className="bg-green-700 hover:bg-green-600 text-white px-2 py-1 rounded-md text-xs"
onClick={(e) => {
e.stopPropagation(); // 阻止事件冒泡,避免触发卡片点击
handleOpenOpinionModal(reviewPoint);
}}
>
<i className="ri-chat-1-line mr-1"></i>
</button>
</div>
{renderStatusBadge(reviewPoint.status, reviewPoint.result,reviewPoint.title)}
</div>
</div>
{/* 评查点内容和操作 */}
{renderReviewPointContent(reviewPoint)}
</div>
))
) : (
renderEmptyState()
)}
</div>
</div>
{/* 提出意见模态框 */}
<Modal
isOpen={isOpinionModalOpen}
onClose={handleCloseOpinionModal}
title="提出意见"
size="medium"
footer={
<div className="flex justify-end space-x-3">
<button
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
onClick={handleCloseOpinionModal}
disabled={isSubmittingOpinion}
>
</button>
<button
className="px-4 py-2 bg-green-700 border border-transparent rounded-md text-sm font-medium text-white hover:bg-green-600 disabled:opacity-50"
onClick={handleSubmitOpinion}
disabled={isSubmittingOpinion}
>
{isSubmittingOpinion ? '提交中...' : '发起投票'}
</button>
</div>
}
>
<div className="space-y-4">
{/* 审查点 */}
<div>
<label htmlFor="audit-point" className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
id="audit-point"
type="text"
value={opinionForm.auditPoint}
readOnly
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-sm focus:outline-none"
/>
</div>
{/* 发现问题 */}
<div>
<label htmlFor="found-issue" className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
id="found-issue"
type="text"
value={opinionForm.foundIssue}
readOnly
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-sm focus:outline-none"
/>
</div>
{/* 审查意见 */}
<div>
<label htmlFor="audit-opinion" className="block text-sm font-medium text-gray-700 mb-2">
<span className="text-red-500">*</span>
</label>
<textarea
id="audit-opinion"
value={opinionForm.auditOpinion}
onChange={(e) => handleOpinionFormChange('auditOpinion', e.target.value)}
placeholder="请输入审查意见..."
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-1 focus:ring-green-700 focus:border-green-700 focus:outline-none"
/>
</div>
{/* 扣分 */}
<div>
<label htmlFor="deduction-score" className="block text-sm font-medium text-gray-700 mb-2">
<span className="text-red-500">*</span>
</label>
<input
id="deduction-score"
type="number"
value={opinionForm.deductionScore}
onChange={(e) => {
const value = parseFloat(e.target.value);
if (!isNaN(value) && value >= 0) {
// 限制到1位小数
const roundedValue = Math.round(value * 10) / 10;
handleOpinionFormChange('deductionScore', roundedValue);
}
}}
step="0.1"
min="0"
max="100"
placeholder="0.0"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-1 focus:ring-green-700 focus:border-green-700 focus:outline-none"
/>
<p className="mt-1 text-xs text-gray-500">1</p>
</div>
</div>
</Modal>
</div>
</>
);
}