3850d05bdd
2. 给文档类型添加入口模块和相关数据的渲染。并且给文档类型进行功能上的角色权限区分 3. 新增角色权限管理页面
3050 lines
116 KiB
TypeScript
3050 lines
116 KiB
TypeScript
/**
|
||
* 评查点列表组件
|
||
*
|
||
* 功能概述:
|
||
* - 展示评查结果统计信息(总计、通过、警告、错误数量)
|
||
* - 提供评查点过滤功能(按状态和搜索文本)
|
||
* - 显示评查点详细信息(标题、状态、内容、建议修改等)
|
||
* - 支持评查点操作(一键替换、人工审核等)
|
||
*
|
||
* 组件结构:
|
||
* - 统计区域: 显示评查点数量统计
|
||
* - 搜索区域: 提供文本搜索功能
|
||
* - 评查点列表: 展示所有评查点
|
||
* - 评查点卡片: 展示单个评查点详情
|
||
* - 评查点头部: 显示标题和状态
|
||
* - 评查点内容: 显示当前内容和问题
|
||
* - 建议修改区域: 显示建议的修改内容
|
||
*/
|
||
import { useState, useEffect, useRef } from 'react';
|
||
import { toastService } from '../ui/Toast';
|
||
import { createPortal } from 'react-dom'; // 导入React Portal API,用于将组件渲染到DOM树的不同位置
|
||
import { Tooltip } from '../ui/Tooltip';
|
||
import { Modal } from '../ui/Modal';
|
||
import { Table } from '../ui/Table';
|
||
import { Pagination } from '../ui/Pagination';
|
||
import { Button } from '../ui/Button';
|
||
import { LoadingIndicator } from '../ui/SkeletonScreen';
|
||
import {
|
||
performOpinionAction,
|
||
type CrossCheckingOpinion,
|
||
type OpinionActionType
|
||
} from '../../api/cross-checking/cross-file-result';
|
||
import { useFetcher, useNavigate } from '@remix-run/react';
|
||
import { API_BASE_URL } from '~/config/api-config';
|
||
import axios from 'axios';
|
||
// 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;
|
||
evaluationPointId?: string | number; // 新增,允许兜底
|
||
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;
|
||
score?: number; // 评查点满分
|
||
finalScore?: number | null; // 评查点最终获得分数
|
||
machineScore?: number; // 评查点机器获得分数
|
||
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 UserInfo {
|
||
id: number;
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
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[];
|
||
jwtToken?: string; // 添加JWT token参数
|
||
userInfo?: UserInfo; // 添加用户信息参数
|
||
onOpinionSubmitted?: (newProposal: ScoringProposal) => void; // 新增:意见提交成功后的回调
|
||
}
|
||
|
||
/**
|
||
* 全局状态对象,存储当前活动的提示框信息
|
||
* 这种方式避免了复杂的状态提升或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 = [],
|
||
jwtToken,
|
||
userInfo,
|
||
onOpinionSubmitted
|
||
}: ReviewPointsListProps) {
|
||
// 状态管理
|
||
const [searchText, setSearchText] = useState(''); // 搜索文本
|
||
const [statusFilter, setStatusFilter] = useState<string | null>(null); // 状态过滤
|
||
const [evaluationResultIds, setEvaluationResultIds] = useState<number[]>([]); // 评分提案的evaluation_result_id
|
||
const [localScoringProposals, setLocalScoringProposals] = useState<ScoringProposal[]>(scoringProposals); // 本地状态管理scoringProposals
|
||
const fetcher = useFetcher();
|
||
|
||
// 归一化 reviewPoints,确保每个点都有 id 字段
|
||
// const [normalizedReviewPoints, setNormalizedReviewPoints] = useState<ReviewPoint[]>([]);
|
||
// console.log('normalizedReviewPoints', normalizedReviewPoints);
|
||
// useEffect(() => {
|
||
// const norm = reviewPoints.map(point => ({
|
||
// ...point,
|
||
// id: String(point.id || point.evaluationPointId || point.pointId || '') // 保证 id 为字符串且不为 undefined
|
||
// }));
|
||
// setNormalizedReviewPoints(norm);
|
||
// }, [reviewPoints]);
|
||
|
||
// 同步外部scoringProposals到本地状态
|
||
useEffect(() => {
|
||
setLocalScoringProposals(scoringProposals);
|
||
}, [scoringProposals]);
|
||
|
||
// 在组件中使用localScoringProposals
|
||
useEffect(() => {
|
||
if (localScoringProposals && localScoringProposals.length > 0) {
|
||
// console.log('收到评分提案数据:', localScoringProposals.length, '个提案');
|
||
// 获取提案的evaluation_result_id
|
||
const evaluationResultIds = localScoringProposals.map(proposal => Number(proposal.evaluation_result_id));
|
||
setEvaluationResultIds(evaluationResultIds);
|
||
// console.log('提案的evaluation_result_id:', evaluationResultIds);
|
||
}
|
||
}, [localScoringProposals]);
|
||
|
||
// 提出意见模态框相关状态
|
||
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);
|
||
|
||
// 意见列表模态框相关状态
|
||
const [isOpinionListModalOpen, setIsOpinionListModalOpen] = useState(false);
|
||
const [opinionListData, setOpinionListData] = useState<CrossCheckingOpinion[]>([]);
|
||
const [opinionListLoading, setOpinionListLoading] = useState(false);
|
||
const [opinionListTotal, setOpinionListTotal] = useState(0);
|
||
const [opinionListCurrentPage, setOpinionListCurrentPage] = useState(1);
|
||
const [opinionListPageSize, setOpinionListPageSize] = useState(10);
|
||
const [performingAction, setPerformingAction] = useState<string | null>(null);
|
||
|
||
// 监听fetcher状态变化 - 获取意见列表数据
|
||
useEffect(() => {
|
||
if (fetcher.data && fetcher.state === 'idle' && opinionListLoading) {
|
||
const data = fetcher.data as {
|
||
success?: boolean;
|
||
data?: {
|
||
opinions: CrossCheckingOpinion[];
|
||
total: number;
|
||
pagination?: {
|
||
page: number;
|
||
page_size: number;
|
||
total: number;
|
||
total_pages: number;
|
||
};
|
||
};
|
||
error?: string;
|
||
};
|
||
if (data.success && data.data) {
|
||
console.log('data.data', data.data);
|
||
setOpinionListData(data.data.opinions || []);
|
||
setOpinionListTotal(data.data.total || 0);
|
||
if (data.data.pagination) {
|
||
setOpinionListCurrentPage(data.data.pagination.page);
|
||
setOpinionListPageSize(data.data.pagination.page_size);
|
||
}
|
||
} else {
|
||
toastService.error(data.error || '加载意见列表失败');
|
||
}
|
||
setOpinionListLoading(false);
|
||
}
|
||
}, [fetcher.data, fetcher.state, opinionListLoading]);
|
||
|
||
// 监听fetcher状态变化 - 提交意见
|
||
useEffect(() => {
|
||
if (fetcher.data && fetcher.state === 'idle' && isSubmittingOpinion) {
|
||
const data = fetcher.data as {
|
||
success?: boolean;
|
||
error?: string;
|
||
};
|
||
|
||
if (data.success) {
|
||
toastService.success('意见提交成功');
|
||
handleCloseOpinionModal();
|
||
} else {
|
||
console.error('提交意见失败:', data.error);
|
||
toastService.error(data.error || '提交意见失败');
|
||
}
|
||
|
||
setIsSubmittingOpinion(false);
|
||
}
|
||
}, [fetcher.data, fetcher.state, isSubmittingOpinion]);
|
||
|
||
// 存放评查点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 loadOpinionListData = async (page: number = 1, pageSize: number = 10, documentId?: string | number) => {
|
||
// 使用传入的documentId或者从selectedReviewPoint获取
|
||
const targetDocumentId = documentId || selectedReviewPoint?.documentId;
|
||
|
||
if (!targetDocumentId) return;
|
||
|
||
setOpinionListLoading(true);
|
||
try {
|
||
|
||
// 使用 fetcher 调用路由的 action
|
||
const formData = new FormData();
|
||
formData.append("intent", "getCrossCheckingOpinions");
|
||
formData.append("documentId", targetDocumentId.toString());
|
||
formData.append("page", page.toString());
|
||
formData.append("pageSize", pageSize.toString());
|
||
|
||
fetcher.submit(formData, { method: "POST" });
|
||
|
||
} catch (error) {
|
||
console.error('加载意见列表失败:', error);
|
||
toastService.error('加载意见列表失败');
|
||
setOpinionListLoading(false);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 打开意见列表模态框
|
||
*/
|
||
const handleOpenOpinionListModal = (reviewPoint: ReviewPoint) => {
|
||
// console.log('查看reviewPoint', reviewPoint);
|
||
if (localScoringProposals.length === 0) {
|
||
toastService.warning('当前文件尚未有人提出过意见');
|
||
return;
|
||
}
|
||
setSelectedReviewPoint(reviewPoint);
|
||
setIsOpinionListModalOpen(true);
|
||
// console.log('打开意见列表模态框');
|
||
// 直接传递reviewPoint的documentId,避免依赖状态更新
|
||
loadOpinionListData(1, 10, reviewPoint.documentId);
|
||
};
|
||
|
||
/**
|
||
* 关闭意见列表模态框
|
||
*/
|
||
const handleCloseOpinionListModal = () => {
|
||
setIsOpinionListModalOpen(false);
|
||
setOpinionListData([]);
|
||
setOpinionListTotal(0);
|
||
setOpinionListCurrentPage(1);
|
||
setOpinionListPageSize(10);
|
||
};
|
||
|
||
/**
|
||
* 处理意见操作(赞同、反对、撤销投票、撤销意见)
|
||
*/
|
||
const handleOpinionAction = async (opinionId: string | number, action: OpinionActionType) => {
|
||
const actionKey = `${opinionId}-${action}`;
|
||
setPerformingAction(actionKey);
|
||
|
||
try {
|
||
const response = await performOpinionAction({ opinionId, action }, jwtToken, userInfo as { user_id: number } | undefined);
|
||
|
||
if (response.error) {
|
||
toastService.error(response.error);
|
||
return;
|
||
}
|
||
|
||
toastService.success(response.data?.message || '操作成功');
|
||
|
||
// console.log('即将重新加载数据');
|
||
|
||
// 重新加载数据
|
||
await loadOpinionListData(opinionListCurrentPage, opinionListPageSize);
|
||
} catch (error) {
|
||
console.error('操作失败:', error);
|
||
toastService.error(error instanceof Error ? error.message : '操作失败,请稍后重试');
|
||
} finally {
|
||
setPerformingAction(null);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 处理意见列表分页变化
|
||
*/
|
||
const handleOpinionListPageChange = (page: number) => {
|
||
setOpinionListCurrentPage(page);
|
||
loadOpinionListData(page, opinionListPageSize);
|
||
};
|
||
|
||
/**
|
||
* 处理意见列表每页大小变化
|
||
*/
|
||
const handleOpinionListPageSizeChange = (size: number) => {
|
||
setOpinionListPageSize(size);
|
||
loadOpinionListData(1, size);
|
||
};
|
||
|
||
/**
|
||
* 刷新意见列表
|
||
*/
|
||
const handleRefreshOpinionList = () => {
|
||
loadOpinionListData(opinionListCurrentPage, opinionListPageSize);
|
||
};
|
||
|
||
/**
|
||
* 提交意见
|
||
*/
|
||
const handleSubmitOpinion = async () => {
|
||
// 校验表单
|
||
if (!opinionForm.auditOpinion.trim()) {
|
||
toastService.error('请填写审查意见');
|
||
return;
|
||
}
|
||
|
||
if (opinionForm.deductionScore > 100) {
|
||
toastService.error('扣分不能大于100分');
|
||
return;
|
||
}
|
||
|
||
if (!selectedReviewPoint) {
|
||
toastService.error('未选择评查点');
|
||
return;
|
||
}
|
||
|
||
// 新增:详细打印每个校验条件
|
||
// console.log('校验前 selectedReviewPoint:', selectedReviewPoint);
|
||
// console.log('校验前 opinionForm:', opinionForm);
|
||
// console.log('校验前 userInfo:', userInfo);
|
||
// console.log('documentId:', selectedReviewPoint.documentId, 'isNaN:', isNaN(Number(selectedReviewPoint.documentId)), 'typeof:', typeof selectedReviewPoint.documentId);
|
||
// console.log('pointId:', selectedReviewPoint.pointId, 'isNaN:', isNaN(Number(selectedReviewPoint.pointId)), 'typeof:', typeof selectedReviewPoint.pointId);
|
||
// console.log('deductionScore:', opinionForm.deductionScore, 'typeof:', typeof opinionForm.deductionScore, 'isNaN:', isNaN(Number(opinionForm.deductionScore)));
|
||
// console.log('auditOpinion:', opinionForm.auditOpinion, 'trim:', String(opinionForm.auditOpinion).trim(), 'typeof:', typeof opinionForm.auditOpinion);
|
||
// console.log('user_id:', userInfo?.user_id, 'typeof:', typeof userInfo?.user_id);
|
||
|
||
// 更严谨的校验逻辑
|
||
if (
|
||
selectedReviewPoint.documentId === undefined ||
|
||
selectedReviewPoint.pointId === undefined ||
|
||
opinionForm.deductionScore === undefined ||
|
||
opinionForm.auditOpinion === undefined ||
|
||
userInfo?.user_id === undefined ||
|
||
isNaN(Number(selectedReviewPoint.documentId)) ||
|
||
isNaN(Number(selectedReviewPoint.pointId)) ||
|
||
isNaN(Number(opinionForm.deductionScore)) ||
|
||
!String(opinionForm.auditOpinion).trim()
|
||
) {
|
||
toastService.error('请完整填写所有必填项');
|
||
setIsSubmittingOpinion(false);
|
||
return;
|
||
}
|
||
|
||
// 打印所有关键数据
|
||
// console.log('selectedReviewPoint:', selectedReviewPoint);
|
||
// console.log('opinionForm:', opinionForm);
|
||
// console.log('userInfo:', userInfo);
|
||
|
||
// 组装后端要求的字段名和内容
|
||
const data = {
|
||
document_id: Number(selectedReviewPoint.documentId),
|
||
evaluation_point_id: Number(selectedReviewPoint.pointId),
|
||
proposed_score: Number(opinionForm.deductionScore),
|
||
reason: opinionForm.auditOpinion,
|
||
proposer_id: userInfo.user_id,
|
||
problem_message: opinionForm.foundIssue,
|
||
evaluation_result_id: Number(selectedReviewPoint.id),
|
||
};
|
||
if (selectedReviewPoint.evaluationPointId) {
|
||
data.evaluation_result_id = Number(selectedReviewPoint.evaluationPointId);
|
||
}
|
||
// 打印最终请求体
|
||
// console.log('最终请求体:', data);
|
||
// 用 axios + application/json 提交
|
||
try {
|
||
const response = await axios.post(`${API_BASE_URL.replace(/\/$/, '')}/admin/cross_review/proposals`, data, {
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${userInfo.frontend_jwt}`,
|
||
}
|
||
});
|
||
const result = response.data;
|
||
if (response.status === 200) {
|
||
toastService.success('意见提交成功');
|
||
|
||
// 创建新的提案对象
|
||
const newProposal: ScoringProposal = {
|
||
id: result.id || Date.now(), // 使用返回的ID或时间戳作为临时ID
|
||
evaluation_result_id: data.evaluation_result_id,
|
||
proposer_id: data.proposer_id as number,
|
||
proposed_score: data.proposed_score,
|
||
reason: data.reason,
|
||
status: 'pending', // 默认状态
|
||
created_at: new Date().toISOString(),
|
||
updated_at: new Date().toISOString(),
|
||
document_id: data.document_id
|
||
};
|
||
|
||
// 更新本地状态
|
||
setLocalScoringProposals(prev => [...prev, newProposal]);
|
||
|
||
// 调用父组件回调(如果提供)
|
||
if (onOpinionSubmitted) {
|
||
onOpinionSubmitted(newProposal);
|
||
}
|
||
|
||
handleCloseOpinionModal();
|
||
} else {
|
||
toastService.error(result.detail || '提交意见失败');
|
||
}
|
||
} catch (error) {
|
||
console.error('提交意见失败:', error);
|
||
toastService.error('提交意见失败,请稍后重试');
|
||
}
|
||
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;
|
||
};
|
||
|
||
// 在ReviewPointsList组件内部
|
||
useEffect(() => {
|
||
if (isOpinionListModalOpen && selectedReviewPoint?.documentId) {
|
||
loadOpinionListData(1, opinionListPageSize);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [isOpinionListModalOpen, selectedReviewPoint?.documentId]);
|
||
|
||
// 组件主渲染函数
|
||
return (
|
||
<>
|
||
<div className="relative">
|
||
{/* 悬浮的意见数量显示 - 固定在左侧 */}
|
||
<button
|
||
className="absolute left-[-35px] top-16 z-10 group cursor-pointer"
|
||
onClick={() => handleOpenOpinionListModal(reviewPoints[0])}
|
||
type="button"
|
||
aria-label="查看意见列表"
|
||
>
|
||
{/* 默认状态:竖向排列,窄宽度 */}
|
||
<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">
|
||
<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>
|
||
</div>
|
||
</button>
|
||
|
||
<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 ml-1">*</span>
|
||
{/* 已获得分数需要判断finalScore是否为null,为null时应该用machineScore(说明该评查点没有修改过分数),否则用finalScore */}
|
||
<span className="text-xs text-gray-500 ml-1">该评查点满分 {selectedReviewPoint?.score} 分,已获得 {selectedReviewPoint?.finalScore !== null ? selectedReviewPoint?.finalScore : selectedReviewPoint?.machineScore} 分</span>
|
||
</label>
|
||
<input
|
||
id="deduction-score"
|
||
type="number"
|
||
value={opinionForm.deductionScore}
|
||
onChange={(e) => {
|
||
const value = parseFloat(e.target.value);
|
||
if (!isNaN(value)) {
|
||
// 限制到1位小数
|
||
const roundedValue = Math.round(value * 10) / 10;
|
||
handleOpinionFormChange('deductionScore', roundedValue);
|
||
}
|
||
}}
|
||
step="0.1"
|
||
min="-100"
|
||
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>
|
||
|
||
|
||
{/* 意见列表模态框 */}
|
||
<Modal
|
||
isOpen={isOpinionListModalOpen}
|
||
onClose={handleCloseOpinionListModal}
|
||
title="意见列表"
|
||
size="full"
|
||
className="opinion-list-modal"
|
||
>
|
||
<div className="px-6 py-4">
|
||
{/* 刷新按钮 */}
|
||
<div className="mb-4 flex justify-between items-center">
|
||
<div className="flex items-center">
|
||
<i className="ri-chat-1-line text-primary text-lg mr-2"></i>
|
||
<span className="text-sm text-secondary">共有</span>
|
||
<span className="text-base font-normal text-primary ml-1 mr-1">{opinionListTotal}</span>
|
||
<span className="text-sm text-secondary">条意见</span>
|
||
</div>
|
||
<Button
|
||
type="default"
|
||
size="small"
|
||
icon="ri-refresh-line"
|
||
onClick={handleRefreshOpinionList}
|
||
disabled={opinionListLoading}
|
||
>
|
||
刷新
|
||
</Button>
|
||
</div>
|
||
|
||
{opinionListLoading ? (
|
||
<div className="py-8">
|
||
<LoadingIndicator text="正在加载意见列表..." />
|
||
</div>
|
||
) : opinionListData.length === 0 ? (
|
||
<div className="text-center py-8 text-gray-500">
|
||
暂无意见数据
|
||
</div>
|
||
) : (
|
||
<>
|
||
<Table
|
||
columns={[
|
||
{
|
||
title: "评查点名称",
|
||
key: "evaluation_point_name",
|
||
width: "15%",
|
||
render: (_: unknown, record: CrossCheckingOpinion) => (
|
||
<div className="text-sm">{record.evaluation_point_name}</div>
|
||
)
|
||
},
|
||
{
|
||
title: "问题描述",
|
||
key: "problem_message",
|
||
width: "18%",
|
||
render: (_: unknown, record: CrossCheckingOpinion) => (
|
||
<div className="text-sm text-left">{record.problem_message}</div>
|
||
)
|
||
},
|
||
{
|
||
title: "调整理由",
|
||
key: "reason",
|
||
width: "15%",
|
||
render: (_: unknown, record: CrossCheckingOpinion) => {
|
||
const reason = record.reason || '';
|
||
const display = reason.length > 20 ? reason.slice(0, 20) + '...' : reason;
|
||
return (
|
||
<span title={reason}>{display}</span>
|
||
);
|
||
}
|
||
},
|
||
{
|
||
title: "调整分数",
|
||
key: "proposed_score",
|
||
width: "5%",
|
||
align: "center" as const,
|
||
render: (_: unknown, record: CrossCheckingOpinion) => (
|
||
<span className={`text-sm font-medium ${record.proposed_score >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||
{record.proposed_score > 0 ? '+' : ''}{record.proposed_score}
|
||
</span>
|
||
)
|
||
},
|
||
{
|
||
title: "投票人",
|
||
key: "votes",
|
||
width: "22%",
|
||
align: "center" as const,
|
||
render: (_: unknown, record: CrossCheckingOpinion) => {
|
||
// 投票类型配置
|
||
const voterGroups = [
|
||
{
|
||
type: "agree",
|
||
voters: record.agree_voters,
|
||
color: "text-green-700",
|
||
bg: "bg-green-100",
|
||
border: "border border-green-200"
|
||
},
|
||
{
|
||
type: "disagree",
|
||
voters: record.disagree_voters,
|
||
color: "text-red-700",
|
||
bg: "bg-red-100",
|
||
border: "border border-red-200"
|
||
},
|
||
{
|
||
type: "pending",
|
||
voters: record.pending_voters,
|
||
color: "text-gray-700",
|
||
bg: "bg-gray-100",
|
||
border: "border border-gray-200"
|
||
}
|
||
];
|
||
return (
|
||
<div className="flex flex-col gap-1.5 py-1 min-w-[120px]">
|
||
{voterGroups.map((group) => (
|
||
Array.isArray(group.voters) && group.voters.length > 0 && (
|
||
<div key={group.type} className="flex flex-wrap gap-1">
|
||
{group.voters.map((name, idx) => (
|
||
<span
|
||
key={`${group.type}-${name}-${idx}`}
|
||
className={`
|
||
px-1.5 py-0.5 rounded text-xs font-medium
|
||
${group.color} ${group.bg} ${group.border}
|
||
whitespace-nowrap overflow-hidden text-ellipsis max-w-[80px]
|
||
transition-all hover:scale-[1.03] hover:shadow-sm
|
||
`}
|
||
>
|
||
{name}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
},
|
||
{
|
||
title: "意见发起人",
|
||
key: "proposer",
|
||
width: "8%",
|
||
render: (_: unknown, record: CrossCheckingOpinion) => (
|
||
<div className="flex items-center justify-center text-left">
|
||
<span
|
||
className="px-1.5 py-0.5 rounded text-xs font-medium text-yellow-700 bg-yellow-100 border border-yellow-200 whitespace-nowrap overflow-hidden text-ellipsis max-w-[80px] transition-all hover:scale-[1.03] hover:shadow-sm"
|
||
>
|
||
{record.proposer}
|
||
</span>
|
||
</div>
|
||
)
|
||
},
|
||
{
|
||
title: "发起时间",
|
||
key: "created_at",
|
||
width: "12%",
|
||
render: (_: unknown, record: CrossCheckingOpinion) => (
|
||
<div className="text-sm text-left">{record.created_at}</div>
|
||
)
|
||
},
|
||
{
|
||
title: "投票状态",
|
||
key: "opinion_status",
|
||
width: "12%",
|
||
render: (_: unknown, record: CrossCheckingOpinion) => {
|
||
let label = '';
|
||
let color = '';
|
||
switch (record.status) {
|
||
case 'approved':
|
||
label = '通过';
|
||
color = 'text-green-600';
|
||
break;
|
||
case 'rejected':
|
||
label = '不通过';
|
||
color = 'text-red-600';
|
||
break;
|
||
case 'pending':
|
||
default:
|
||
label = '投票中';
|
||
color = 'text-yellow-600';
|
||
break;
|
||
}
|
||
return <span className={`font-bold ${color}`}>{label}</span>;
|
||
}
|
||
},
|
||
{
|
||
title: "操作",
|
||
key: "operation",
|
||
width: "auto",
|
||
align: "center" as const,
|
||
render: (_: unknown, record: CrossCheckingOpinion) => {
|
||
const isPerforming = (action: string) => performingAction === `${record.proposal_id}-${action}`;
|
||
return (
|
||
<OpinionActions record={record} isPerforming={isPerforming} handleOpinionAction={handleOpinionAction} userInfo={userInfo as { user_id: number } | undefined} />
|
||
);
|
||
}
|
||
}
|
||
]}
|
||
dataSource={opinionListData}
|
||
rowKey="proposal_id"
|
||
emptyText="暂无意见数据"
|
||
className="opinion-list-table"
|
||
/>
|
||
|
||
{/* 分页组件 */}
|
||
{opinionListTotal > 0 && (
|
||
<Pagination
|
||
currentPage={opinionListCurrentPage}
|
||
total={opinionListTotal}
|
||
pageSize={opinionListPageSize}
|
||
onChange={handleOpinionListPageChange}
|
||
onPageSizeChange={handleOpinionListPageSizeChange}
|
||
showTotal={true}
|
||
showPageSizeChanger={true}
|
||
pageSizeOptions={[5,10, 20, 30, 50]}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</Modal>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// 操作按钮区美化+弹窗确认组件
|
||
function OpinionActions({ record, isPerforming, handleOpinionAction, userInfo }: {
|
||
record: CrossCheckingOpinion;
|
||
isPerforming: (action: string) => boolean;
|
||
handleOpinionAction: (id: string | number, action: OpinionActionType) => void;
|
||
userInfo?: { user_id: number };
|
||
}) {
|
||
const [showModal, setShowModal] = useState<null | OpinionActionType>(null);
|
||
const [countdown, setCountdown] = useState(3);
|
||
const [counting, setCounting] = useState(false);
|
||
|
||
useEffect(() => {
|
||
let timer: NodeJS.Timeout;
|
||
if (
|
||
showModal &&
|
||
(showModal === 'withdraw_opinion' || showModal === 'withdraw_vote') &&
|
||
counting &&
|
||
countdown > 0
|
||
) {
|
||
timer = setTimeout(() => {
|
||
setCountdown((c) => c - 1);
|
||
}, 1000);
|
||
} else if (countdown === 0) {
|
||
setCounting(false);
|
||
}
|
||
return () => clearTimeout(timer);
|
||
}, [showModal, counting, countdown]);
|
||
|
||
const handleConfirm = () => {
|
||
if (showModal === 'withdraw_opinion' || showModal === 'withdraw_vote') {
|
||
if (countdown === 0) {
|
||
handleOpinionAction(record.proposal_id, showModal);
|
||
setShowModal(null);
|
||
setCountdown(3);
|
||
setCounting(false);
|
||
}
|
||
} else {
|
||
// 赞同/反对等操作直接执行
|
||
handleOpinionAction(record.proposal_id, showModal!);
|
||
setShowModal(null);
|
||
setCountdown(3);
|
||
setCounting(false);
|
||
}
|
||
};
|
||
const handleCancel = () => {
|
||
setShowModal(null);
|
||
setCountdown(3);
|
||
setCounting(false);
|
||
};
|
||
|
||
// 判断是否是发起人
|
||
const isProposer = userInfo && record.proposer_id === userInfo.user_id;
|
||
|
||
return (
|
||
<div className="flex gap-3">
|
||
{/* 仅当can_vote为true时显示赞同/反对按钮 */}
|
||
{record.can_vote && (
|
||
<>
|
||
<Button
|
||
type="default"
|
||
className="bg-green-700 hover:bg-green-800 text-white min-w-[80px] h-14 text-base font-medium rounded-lg flex items-center justify-center whitespace-nowrap shadow-md hover:shadow-lg transition-all duration-200 px-4 py-3"
|
||
onClick={() => { setShowModal('agree'); }}
|
||
disabled={isPerforming('agree')}
|
||
>
|
||
{isPerforming('agree') ? '处理中...' : '赞同'}
|
||
</Button>
|
||
<Button
|
||
type="default"
|
||
className="bg-red-700 hover:bg-red-800 text-white min-w-[80px] h-14 text-base font-medium rounded-lg flex items-center justify-center whitespace-nowrap shadow-md hover:shadow-lg transition-all duration-200 px-4 py-3"
|
||
onClick={() => { setShowModal('disagree'); }}
|
||
disabled={isPerforming('disagree')}
|
||
>
|
||
{isPerforming('disagree') ? '处理中...' : '反对'}
|
||
</Button>
|
||
</>
|
||
)}
|
||
{/* 仅当can_vote为false时显示撤销投票按钮 */}
|
||
{!record.can_vote && !isProposer && (
|
||
<Button
|
||
type="default"
|
||
className="bg-yellow-600 hover:bg-yellow-700 text-white min-w-[80px] h-14 text-base font-medium rounded-lg flex items-center justify-center whitespace-nowrap shadow-md hover:shadow-lg transition-all duration-200 px-4 py-3"
|
||
onClick={() => { setShowModal('withdraw_vote'); setCounting(true); }}
|
||
disabled={isPerforming('withdraw_vote')}
|
||
>
|
||
{isPerforming('withdraw_vote') ? '处理中...' : '撤销投票'}
|
||
</Button>
|
||
)}
|
||
{/* 仅当是发起人才显示撤销意见按钮 */}
|
||
{isProposer && (
|
||
<Button
|
||
type="default"
|
||
className="bg-yellow-600 hover:bg-red-700 text-white min-w-[80px] h-14 text-base font-medium rounded-lg flex items-center justify-center whitespace-nowrap shadow-md hover:shadow-lg transition-all duration-200 px-4 py-3"
|
||
onClick={() => { setShowModal('withdraw_opinion'); setCounting(true); }}
|
||
disabled={isPerforming('withdraw_opinion')}
|
||
>
|
||
{isPerforming('withdraw_opinion') ? '处理中...' : '撤销意见'}
|
||
</Button>
|
||
)}
|
||
{/* 确认操作模态框 */}
|
||
{showModal && (
|
||
<Modal
|
||
isOpen={!!showModal}
|
||
onClose={handleCancel}
|
||
title="确认操作"
|
||
size="small"
|
||
className=""
|
||
footer={
|
||
<div className="flex justify-end gap-3">
|
||
<Button
|
||
type="default"
|
||
className="min-w-[80px] h-14 text-base font-medium rounded-lg flex items-center justify-center whitespace-nowrap bg-gray-500 hover:bg-gray-600 text-white shadow-md hover:shadow-lg transition-all duration-200 px-4 py-3"
|
||
onClick={handleCancel}
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
type="default"
|
||
className={`bg-green-700 hover:bg-green-800 text-white min-w-[80px] h-14 text-base font-medium rounded-lg flex items-center justify-center whitespace-nowrap shadow-md hover:shadow-lg transition-all duration-200 px-4 py-3 ${(showModal === 'withdraw_opinion' || showModal === 'withdraw_vote') && countdown > 0 ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||
onClick={handleConfirm}
|
||
disabled={(showModal === 'withdraw_opinion' || showModal === 'withdraw_vote') && countdown > 0}
|
||
>
|
||
{(showModal === 'withdraw_opinion' || showModal === 'withdraw_vote') && countdown > 0 ? `确认(${countdown})` : '确认'}
|
||
</Button>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="flex flex-col items-center justify-center text-base text-gray-700 py-4 text-center">
|
||
<div className="mb-2">确定要进行此操作吗?</div>
|
||
<div className="text-sm text-gray-500">评查点:<span className="font-bold text-primary">{record.evaluation_point_name || record.proposal_id}</span></div>
|
||
</div>
|
||
</Modal>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 交叉评查记录类型定义
|
||
export interface CrossCheckingRecord {
|
||
id: string;
|
||
status: 'pending' | 'in_progress' | 'completed';
|
||
// 可以根据需要添加更多字段
|
||
}
|
||
|
||
// 打开结果弹窗的函数(需要根据实际需求实现)
|
||
const openResultModal = (recordId: string) => {
|
||
// 这里实现打开结果弹窗的逻辑
|
||
console.log('打开结果弹窗:', recordId);
|
||
};
|
||
|
||
// 交叉评查记录操作按钮组件
|
||
export function ActionButtons({ record }: { record: CrossCheckingRecord }) {
|
||
const navigate = useNavigate();
|
||
|
||
// 根据记录状态确定按钮类型
|
||
const getButtonConfig = () => {
|
||
switch (record.status) {
|
||
case 'pending':
|
||
return {
|
||
text: '去评查',
|
||
bgColor: 'bg-blue-600',
|
||
hoverColor: 'hover:bg-blue-700',
|
||
icon: <span className="ri-edit-2-line text-lg mr-1"></span>
|
||
};
|
||
case 'in_progress':
|
||
return {
|
||
text: '进行中',
|
||
bgColor: 'bg-gray-500',
|
||
hoverColor: 'hover:bg-gray-600',
|
||
icon: <span className="ri-loader-4-line text-lg mr-1 animate-spin"></span>
|
||
};
|
||
case 'completed':
|
||
default:
|
||
return {
|
||
text: '查看结果',
|
||
bgColor: 'bg-green-600',
|
||
hoverColor: 'hover:bg-green-700',
|
||
icon: <span className="ri-eye-line text-lg mr-1"></span>
|
||
};
|
||
}
|
||
};
|
||
|
||
const buttonConfig = getButtonConfig();
|
||
|
||
/**
|
||
* 处理按钮点击事件
|
||
* 使用React Router的navigate方法替代window.location.href,避免页面刷新
|
||
*/
|
||
const handleAction = () => {
|
||
switch (record.status) {
|
||
case 'pending':
|
||
// 使用navigate跳转到评查页面,避免页面刷新
|
||
navigate(`/review/${record.id}`);
|
||
break;
|
||
case 'in_progress':
|
||
// 进行中状态不执行操作
|
||
break;
|
||
case 'completed':
|
||
// 打开结果弹窗或页面
|
||
openResultModal(record.id);
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
};
|
||
|
||
return (
|
||
<button
|
||
onClick={handleAction}
|
||
disabled={record.status === 'in_progress'}
|
||
className={`
|
||
flex items-center justify-center
|
||
px-4 py-2 rounded-lg text-white text-sm font-medium
|
||
shadow transition-all duration-200
|
||
${buttonConfig.bgColor}
|
||
${buttonConfig.hoverColor}
|
||
${record.status === 'in_progress'
|
||
? 'cursor-not-allowed opacity-90'
|
||
: 'transform hover:-translate-y-0.5 hover:shadow-md'}
|
||
min-w-[100px] whitespace-nowrap
|
||
`}
|
||
>
|
||
{buttonConfig.icon}
|
||
{buttonConfig.text}
|
||
</button>
|
||
);
|
||
} |