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

3433 lines
133 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 评查点列表组件
*
* 功能概述:
* - 展示评查结果统计信息(总计、通过、警告、错误数量)
* - 提供评查点过滤功能(按状态和搜索文本)
* - 显示评查点详细信息(标题、状态、内容、建议修改等)
* - 支持评查点操作(一键替换、人工审核等)
*
* 组件结构:
* - 统计区域: 显示评查点数量统计
* - 搜索区域: 提供文本搜索功能
* - 评查点列表: 展示所有评查点
* - 评查点卡片: 展示单个评查点详情
* - 评查点头部: 显示标题和状态
* - 评查点内容: 显示当前内容和问题
* - 建议修改区域: 显示建议的修改内容
*/
import { useState, useEffect, useRef } from 'react';
import { toastService } from '../ui/Toast';
import { createPortal } from 'react-dom'; // 导入React Portal API,用于将组件渲染到DOM树的不同位置
import { Tooltip } from '../ui/Tooltip';
import { Modal } from '../ui/Modal';
import { Table } from '../ui/Table';
import { Pagination } from '../ui/Pagination';
import { Button } from '../ui/Button';
import { LoadingIndicator } from '../ui/SkeletonScreen';
import {
performOpinionAction,
submitCrossCheckingOpinion,
type CrossCheckingOpinion,
type OpinionActionType,
type SubmitOpinionRequest
} from '../../api/cross-checking/cross-file-result';
import { useFetcher, useNavigate } from '@remix-run/react';
import { CorporateInfoModal } from '../corporate-information';
import type { BusinessInfoResult, DishonestyResult } from '../corporate-information';
import { queryCompanyInfo } from '~/api/corporate-information/qichacha';
// import '../../styles/components/TooltipStyles.css';
/**
* 比较方法映射
* 将后端返回的比较方法英文值映射为友好的中文显示
*/
const compareMethodMap: Record<string, string> = {
'exact': '精确匹配',
'contains': '包含关系',
'semantic': '大模型语义匹配',
// 可以根据需要添加更多映射
};
/**
* 获取比较方法的中文显示
* @param method 比较方法的原始值
* @returns 映射后的中文显示文本
*/
const getCompareMethodText = (method?: string): string => {
if (!method) return '相等';
const text = compareMethodMap[method] || method;
// 确保返回的是字符串类型
return typeof text === 'string' ? text : String(text);
};
/**
* 规则类型映射
* 将后端返回的规则类型英文值映射为友好的中文显示
*/
const ruleTypeMap: Record<string, string> = {
'exists': '有无判断',
'format': '格式判断',
'logic': '逻辑判断',
'regex': '正则表达式',
// 可以根据需要添加更多映射
};
/**
* 获取规则类型的中文显示
* @param type 规则类型的原始值
* @returns 映射后的中文显示文本
*/
const getRuleTypeText = (type?: string): string => {
if (!type) return '';
return ruleTypeMap[type] || type;
};
/**
* 字符位置类型定义
* 用于定位文档中具体的文字位置
*/
export interface CharPosition {
box: number[][]; // 字符边界框坐标
char: string; // 字符内容
score: number; // OCR识别置信度
}
/**
* text_bbox -> CharPosition[] 转换
* GraphRAG 抽取结果只有 text_bbox (段落级坐标), 没有 char_positions (字符级坐标)。
* 将 text_bbox 转为单个 CharPosition 矩形框, 让 PdfPreview 的高亮逻辑复用。
*/
function resolveCharPositions(data: any): CharPosition[] | undefined {
// 优先用 char_positions
if (data?.char_positions && data.char_positions.length > 0) {
return data.char_positions;
}
// fallback: text_bbox -> CharPosition[]
if (data?.text_bbox) {
const b = data.text_bbox;
if (b.x_min != null && b.y_min != null && b.x_max != null && b.y_max != null
&& (b.x_max - b.x_min) > 0 && (b.y_max - b.y_min) > 0) {
return [{
box: [[b.x_min, b.y_min], [b.x_max, b.y_min], [b.x_max, b.y_max], [b.x_min, b.y_max]],
char: '',
score: 1
}];
}
}
return undefined;
}
/**
* 评查点类型定义
* 用于展示单个评查结果
*/
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, charPositions?: CharPosition[], value?: string) => void;
onStatusChange?: (id: string, editAuditStatusId: string | number, status: string, message: string) => void;
scoringProposals?: ScoringProposal[];
jwtToken?: string; // 添加JWT token参数
userInfo?: UserInfo; // 添加用户信息参数
onOpinionSubmitted?: (newProposal: ScoringProposal) => void; // 新增:意见提交成功后的回调
fileFormat?: string; // 文件格式(用于判断是否为PDF)
onAiSuggestionReplace?: (searchText: string, replaceText: string, pageNumber: number) => void; // AI建议替换回调
// 权限控制
canReadProposal?: boolean; // 查看意见列表权限
canCreateProposal?: boolean; // 提出建议权限
canDeleteProposal?: boolean; // 撤销意见权限
canVoteProposal?: boolean; // 赞同/反对权限
}
/**
* 全局状态对象,存储当前活动的提示框信息
* 这种方式避免了复杂的状态提升或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,
fileFormat,
onAiSuggestionReplace,
// 权限控制 - 默认为 true 保持向后兼容
canReadProposal = true,
canCreateProposal = true,
canDeleteProposal = true,
canVoteProposal = true
}: 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 [corporateModalVisible, setCorporateModalVisible] = useState(false);
const [corporateCompanyName, setCorporateCompanyName] = useState('');
const [corporateBusinessInfo, setCorporateBusinessInfo] = useState<BusinessInfoResult | null>(null);
const [corporateDishonestyInfo, setCorporateDishonestyInfo] = useState<DishonestyResult | null>(null);
const [corporateLoading, setCorporateLoading] = useState(false);
const [corporateError, setCorporateError] = useState<string | null>(null);
const [corporateUpdatedAt, setCorporateUpdatedAt] = useState<string | null>(null);
/**
* 处理企业信息按钮点击
* @param companyName 企业名称(乙方名称)
* @param forceRefresh 是否强制刷新(对接企查查重新查询)
*/
const handleCorporateInfoClick = async (companyName: string, forceRefresh = false) => {
if (!companyName) {
toastService.warning('企业名称为空,无法查询');
return;
}
// 打开模态框并设置加载状态
setCorporateModalVisible(true);
setCorporateCompanyName(companyName);
setCorporateLoading(true);
setCorporateError(null);
setCorporateBusinessInfo(null);
setCorporateDishonestyInfo(null);
setCorporateUpdatedAt(null);
try {
const response = await queryCompanyInfo({ keyword: companyName, force_refresh: forceRefresh });
if (response.success && response.data) {
setCorporateBusinessInfo(response.data.enterprise);
setCorporateUpdatedAt(response.data.updated_at);
// 转换失信数据格式
if (response.data.dishonesty) {
setCorporateDishonestyInfo({
VerifyResult: response.data.dishonesty.VerifyResult,
Data: response.data.dishonesty.Data || [],
});
}
} else {
setCorporateError(response.message || '查询失败');
}
} catch (error) {
console.error('查询企业信息失败:', error);
setCorporateError(error instanceof Error ? error.message : '查询失败');
} finally {
setCorporateLoading(false);
}
};
/**
* 处理强制刷新(对接企查查重新查询)
*/
const handleCorporateForceRefresh = async () => {
if (corporateCompanyName) {
await handleCorporateInfoClick(corporateCompanyName, true);
}
};
/**
* 关闭企业信息模态框
*/
const handleCloseCorporateModal = () => {
setCorporateModalVisible(false);
setCorporateCompanyName('');
setCorporateBusinessInfo(null);
setCorporateDishonestyInfo(null);
setCorporateError(null);
setCorporateUpdatedAt(null);
};
/**
* 打开提出意见模态框
*/
const 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);
// console.log('jwtToken:', jwtToken);
// 组装 submitCrossCheckingOpinion 需要的参数
const opinionData: SubmitOpinionRequest = {
reviewPointResultId: data.evaluation_result_id,
documentId: data.document_id,
evaluationPointId: data.evaluation_point_id,
auditOpinion: data.reason,
deductionScore: data.proposed_score
};
try {
const response = await submitCrossCheckingOpinion(
opinionData,
jwtToken,
{ user_id: Number(userInfo.user_id) }
);
if (response.error) {
throw new Error(response.error);
}
if (response.data?.success) {
toastService.success('意见提交成功');
// 创建新的提案对象
const newProposal: ScoringProposal = {
id: response.data.data?.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: response.data.data?.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 {
throw new Error('提交意见失败');
}
} catch (error) {
console.error('提交意见失败:', error);
const errorMessage = error instanceof Error ? error.message : '提交意见失败,请稍后重试';
toastService.error(errorMessage);
}
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; char_positions?: CharPosition[] }>;
targetField: Record<string, { page: number; value: string; char_positions?: CharPosition[] }>;
res: boolean;
compareMethod?: string;
}>;
selectedFields?: string[]
} | undefined;
if (!config || !config.pairs || !Array.isArray(config.pairs) || config.pairs.length === 0) {
return null;
}
// 处理配对数据
const pairs = config.pairs;
// 获取第一个有效页码
if (reviewPoint.id && !effectivePages[reviewPoint.id]) {
for (const pair of pairs) {
// 检查sourceField中是否有有效页码
const sourceFieldKey = Object.keys(pair.sourceField)[0];
if (sourceFieldKey && pair.sourceField[sourceFieldKey].page &&
Number(pair.sourceField[sourceFieldKey].page) > 0) {
// 保存页码
setEffectivePages(prev => ({
...prev,
[reviewPoint.id || '']: Number(pair.sourceField[sourceFieldKey].page)
}));
break;
}
// 如果sourceField没有有效页码,检查targetField
const targetFieldKey = Object.keys(pair.targetField)[0];
if (targetFieldKey && pair.targetField[targetFieldKey].page &&
Number(pair.targetField[targetFieldKey].page) > 0) {
// 保存页码
setEffectivePages(prev => ({
...prev,
[reviewPoint.id || '']: Number(pair.targetField[targetFieldKey].page)
}));
break;
}
}
}
// 查找链条关系
const findChains = () => {
type ChainItem = {
field: string;
data: {
key: string;
page: number;
value: string;
char_positions?: CharPosition[];
};
res: boolean;
compareMethod?: string;
};
const chains: Array<Array<ChainItem>> = [];
const visited = new Set<string>();
// 构建字段映射关系
const fieldMap = new Map<string, Array<{
targetField: string;
data: {
source: { key: string; page: number; value: string; char_positions?: CharPosition[] };
target: { key: string; page: number; value: string; char_positions?: CharPosition[] };
};
res: boolean;
compareMethod?: string;
}>>();
pairs.forEach(pair => {
// 提取源字段和目标字段的名称
const sourceFieldKey = Object.keys(pair.sourceField)[0];
const targetFieldKey = Object.keys(pair.targetField)[0];
if (!fieldMap.has(sourceFieldKey)) {
fieldMap.set(sourceFieldKey, []);
}
fieldMap.get(sourceFieldKey)?.push({
targetField: targetFieldKey,
data: {
source: { key: sourceFieldKey, ...pair.sourceField[sourceFieldKey] },
target: { key: targetFieldKey, ...pair.targetField[targetFieldKey] }
},
res: pair.res,
compareMethod: pair.compareMethod
});
});
// console.log('fieldMap-------', fieldMap);
// 查找链条的起始点(只作为源不作为目标的字段)
const startPoints = new Set<string>();
for (const [key] of fieldMap.entries()) {
let isTarget = false;
for (const pair of pairs) {
const targetFieldKey = Object.keys(pair.targetField)[0];
if (targetFieldKey === key) {
isTarget = true;
break;
}
}
if (!isTarget) {
startPoints.add(key);
}
}
// console.log('startPoints-------', startPoints);
// 从每个起始点开始构建链条
for (const startPoint of startPoints) {
if (visited.has(startPoint)) continue;
const tempChain: Array<ChainItem> = [];
let currentField = startPoint;
// 向后构建链条
while (fieldMap.has(currentField)) {
const targets = fieldMap.get(currentField);
if (!targets || targets.length === 0) break;
// 找到第一个未访问的目标
let nextTarget = null;
for (const target of targets) {
if (!visited.has(target.targetField)) {
nextTarget = target;
break;
}
}
if (!nextTarget) break;
// 添加源字段到链条
if (tempChain.length === 0) {
tempChain.push({
field: currentField,
data: nextTarget.data.source,
res: nextTarget.res,
compareMethod: nextTarget.compareMethod
});
}
// 添加目标字段到链条
tempChain.push({
field: nextTarget.targetField,
data: nextTarget.data.target,
res: nextTarget.res,
compareMethod: nextTarget.compareMethod
});
// 标记为已访问
visited.add(currentField);
visited.add(nextTarget.targetField);
// 移动到下一个字段
currentField = nextTarget.targetField;
}
// console.log('tempChain-------', tempChain);
// 检查是否有链条,并处理链条断点
if (tempChain.length > 0) {
// 如果链条长度大于1
if (tempChain.length > 1) {
// 存储所有拆分后的链条
const splittedChains: Array<Array<ChainItem>> = [];
// 从后往前遍历,检查每个相邻元素之间的连接
let endIndex = tempChain.length - 1;
// 从倒数第一个元素开始往前遍历
for (let i = tempChain.length - 1; i > 0; i--) {
// 检查当前元素与前一个元素的连接是否为false
// 当前元素为tempChain[i],前一个元素为tempChain[i-1]
// 连接结果存储在当前元素(tempChain[i])的result中
const connectionResult = tempChain[i].res;
// 如果连接为false,或已到达起始位置,拆分链条
if (!connectionResult) {
// 从当前断点到结束索引构建一个新链条
const newChain = tempChain.slice(i, endIndex + 1);
if (newChain.length > 1) {
splittedChains.push(newChain);
// 将当前断点的前一个元素和后一个元素组成一个新链条
const newChain_before = tempChain.slice(i-1, i+1);
// console.log('newChain_before-------', newChain_before);
splittedChains.push(newChain_before);
}
// 更新结束索引为当前位置的前一个
endIndex = i - 1;
}
// 当到达第一个元素前一个位置时,需要处理剩余的链条
if (i === 1) {
// 处理剩余部分 (0 到 endIndex)
const remainingChain = tempChain.slice(0, endIndex + 1);
if (remainingChain.length > 1) {
splittedChains.push(remainingChain);
}
}
}
// 如果没有任何断点,添加整个链条
if (splittedChains.length === 0) {
splittedChains.push([...tempChain]);
}
// 将拆分的链条添加到结果中
splittedChains.reverse().forEach(chain => {
chains.push(chain);
});
} else {
// 如果链条长度为1,直接添加
chains.push([...tempChain]);
}
}
}
// console.log('chains-------', chains);
// 处理没有找到的孤立对(这种情况只要规则配置是没问题的,就一定不会存在孤立的情况)
for (const pair of pairs) {
const sourceFieldKey = Object.keys(pair.sourceField)[0];
const targetFieldKey = Object.keys(pair.targetField)[0];
if (!visited.has(sourceFieldKey) || !visited.has(targetFieldKey)) {
const isolatedPair: Array<ChainItem> = [
{
field: sourceFieldKey,
data: { key: sourceFieldKey, ...pair.sourceField[sourceFieldKey] },
res: pair.res
},
{
field: targetFieldKey,
data: { key: targetFieldKey, ...pair.targetField[targetFieldKey] },
res: pair.res
}
];
chains.push(isolatedPair);
visited.add(sourceFieldKey);
visited.add(targetFieldKey);
}
}
return chains;
};
const chains = findChains();
return (
<div className="mt-3">
<div className="comparison-group">
{chains.map((chain, chainIndex) => {
const isLongChain = chain.length > 2;
const res = chain[1].res;
// 获取compareMethod
// const compareMethod = chain[1].compareMethod || '';
// 转换为友好的显示文本
// const compareMethodText = getCompareMethodText(compareMethod);
// 确定样式类名
const itemClassName = res
? "comparison-item match"
: "comparison-item mismatch";
// console.log('currentchain-------', chain);
// 如果是长链(3个或以上元素)
if (isLongChain) {
// console.log('currentlongchain-------', chain);
return (
<div
key={`chain_${chainIndex}`}
className={`${itemClassName} border border-gray
rounded-md overflow-hidden mb-2 ${res ? 'bg-[rgba(246,255,237,1)]' : 'bg-[rgba(255,251,230,1)]'}
hover:shadow-[0_0_10px_rgba(0,0,0,0.1)] cursor-pointer ${res ? 'hover:bg-[rgba(0,128,0,0.1)]' : 'hover:bg-[rgba(255,255,0,0.1)]'}`}
onClick={(e) => {
e.stopPropagation();
// 遍历chain找到第一个有效的page
let hasPage = false;
for (const item of chain) {
if (item.data.page && typeof onReviewPointSelect === 'function') {
hasPage = true;
onReviewPointSelect(reviewPoint.id, Number(item.data.page), resolveCharPositions(item.data), item.data.value);
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), resolveCharPositions(item.data), item.data.value);
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), resolveCharPositions(item.data), item.data.value);
}
}
else if(reviewPoint.contentPage && reviewPoint.contentPage[item.field]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[item.field]), resolveCharPositions(item.data), item.data.value);
}
else{
toastService.error(`没有找到${item.field}对应的索引内容`);
}
}}
aria-label={`查看${item.field}内容详情`}
>
<div className="flex justify-between w-full">
<ReactTableTooltip content={item.data.value?.toString() || ''} />
{!item.data.page && (reviewPoint.contentPage && !reviewPoint.contentPage[item.field]) && (
<i className="ri-information-line text-red-500 text-xs" title="没有找到对应的文书内容"></i>
)}
</div>
</button>
))}
</div>
</div>
<div className="status-indicator w-8 flex items-center justify-center group relative" >
{res ? (
<i className="ri-check-line text-success text-base" ></i>
) : (
<i className="ri-alert-line text-warning text-base" ></i>
)}
{/* 使用鼠标事件处理悬停提示 */}
<div
className="w-full h-full absolute top-0 left-0"
onMouseEnter={(e) => {
// 获取元素位置信息
const rect = e.currentTarget.getBoundingClientRect();
// 创建提示框内容
const content = (
<div className="flex flex-row gap-2 overflow-x-auto">
{chain.map((item, idx) =>
idx >= 1 ? (
<div key={idx} className={`rounded-md flex flex-row items-center`}>
<div className="text-xs text-gray-600 whitespace-nowrap pl-2">
{typeof item.compareMethod === 'object'
? ''
: `${getCompareMethodText(item.compareMethod)}:`}
</div>
<div className={`p-1 text-xs rounded-full min-w-[50px] text-center`}>
{res ? '通过' : '不通过'}
</div>
</div>
) : null
)}
</div>
);
// 显示提示框
showTooltip(content, { top: rect.top + rect.height/2, left: rect.left });
}}
onMouseLeave={hideTooltip}
/>
</div>
</div>
</div>
);
}
// 如果是标准的成对比较(2个元素)
return (
<div
key={`pair_${chainIndex}`}
className={`${itemClassName} border border-gray rounded-md overflow-hidden mb-2 ${res ? 'bg-[rgba(246,255,237,1)]' : 'bg-[rgba(255,251,230,1)]'} flex`}
>
<div className="field-label bg-gray-50 p-2 text-xs font-medium text-gray-500 border-r border-gray-200 min-w-[70px] items-center hidden">
{chain[0].field.split('-').pop()}
</div>
<div className="comparison-values flex flex-1">
<button
className={`value-box hover:shadow-[0_0_10px_rgba(0,0,0,0.1)]
flex-1 p-2 border-r-2 ${res ? 'border-green-200' : 'border-yellow-200'} text-left
${res ? 'hover:bg-[rgba(0,128,0,0.1)]' : 'hover:bg-[rgba(255,255,0,0.1)]'} transition-colors flex flex-col`}
onClick={(e) => {
e.stopPropagation();
if (chain[0].data.page) {
const reviewPointId = reviewPoint.id as string;
if (reviewPointId && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPointId, chain[0].data.page, resolveCharPositions(chain[0].data), chain[0].data.value);
}
}
else if(reviewPoint.contentPage && reviewPoint.contentPage[chain[0].field]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[0].field]), resolveCharPositions(chain[0].data), chain[0].data.value);
}
else{
toastService.error(`没有找到${chain[0].field}对应的索引内容`);
}
}}
aria-label={`查看${chain[0].field}内容详情`}
>
<div className="value-source text-xs text-gray-500 mb-1">{chain[0].field}
{!chain[0].data.page && (reviewPoint.contentPage && !reviewPoint.contentPage[chain[0].field]) && (
<i className="ri-information-line text-red-500 text-xs ml-1" title="没有找到对应的文书内容"></i>
)}
</div>
<ReactTableTooltip content={chain[0].data.value?.toString() || ''} />
</button>
<button
className={`value-box flex flex-col flex-1 p-2 text-left ${res ? 'hover:bg-[rgba(0,128,0,0.1)]' : 'hover:bg-[rgba(255,255,0,0.1)]'} transition-colors hover:shadow-[0_0_10px_rgba(0,0,0,0.1)]`}
onClick={(e) => {
e.stopPropagation();
if (chain[1].data.page) {
const reviewPointId = reviewPoint.id as string;
if (reviewPointId && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPointId, chain[1].data.page, resolveCharPositions(chain[1].data), chain[1].data.value);
}
}
else if(reviewPoint.contentPage && reviewPoint.contentPage[chain[1].field]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[1].field]), resolveCharPositions(chain[1].data), chain[1].data.value);
}
else{
toastService.error(`没有找到${chain[1].field}对应的索引内容`);
}
}}
aria-label={`查看${chain[1].field}内容详情`}
>
<div className="value-source text-xs text-gray-500 mb-1">{chain[1].field}
{!chain[1].data.page && (reviewPoint.contentPage && !reviewPoint.contentPage[chain[1].field]) && (
<i className="ri-information-line text-red-500 text-xs ml-1" title="没有找到对应的文书内容"></i>
)}
</div>
<ReactTableTooltip content={chain[1].data.value?.toString() || ''} />
</button>
</div>
<div className="status-indicator tooltip w-8 flex items-center justify-center group relative">
{res ? (
<i className="ri-check-line text-success text-base"></i>
) : (
<i className="ri-alert-line text-warning text-base"></i>
)}
{/* 使用鼠标事件处理悬停提示 */}
<div
className="w-full h-full absolute top-0 left-0"
onMouseEnter={(e) => {
// 获取元素位置信息
const rect = e.currentTarget.getBoundingClientRect();
// 创建提示框内容
const content = (
<div className="flex flex-row gap-2 overflow-x-auto">
<div key={chain[1].compareMethod} className={`rounded-md flex flex-row items-center`}>
<div className="text-xs text-gray-600 whitespace-nowrap pl-1">
{typeof chain[1].compareMethod === 'object'
? ''
: `${getCompareMethodText(chain[1].compareMethod)}:`}
</div>
<div className={`p-1 text-xs rounded-full min-w-[50px] text-center`}>
{res ? '通过' : '不通过'}
</div>
</div>
</div>
);
// 显示提示框,稍微向下偏移,便于鼠标移动到tooltip上
showTooltip(content, {
top: rect.top + rect.height/2,
left: rect.left
});
}}
onMouseLeave={hideTooltip}
/>
</div>
</div>
);
})}
</div>
</div>
);
};
/**
* 渲染评查点有无判断,格式判断,逻辑判断,正则表达式的规则的样式
* @param otherRule 评查点规则数据
* @param reviewPoint 关联的评查点
* @returns 评查点有无判断,格式判断,逻辑判断,正则表达式的规则的样式
*/
const renderOtherRule = (otherRule: Record<string, unknown>, reviewPoint: ReviewPoint) => {
const fieldKey = otherRule.fieldKey as string;
const fieldValue = otherRule.fieldValue as {
type: Record<string, {
res: boolean;
page?: number | string;
value?: string;
char_positions?: CharPosition[];
}>;
};
// 获取res的综合结果
// 如果存在res=false,则整体结果为false,否则为true
const hasFailure = Object.values(fieldValue?.type || {}).some(item => item.res === false);
const overallResult = !hasFailure;
// 找到res为false的条目,用于主要显示
const failedTypeEntry = Object.entries(fieldValue?.type || {}).find(([, item]) => item.res === false);
// 如果没有失败的条目,则使用第一个条目
const mainTypeEntry = failedTypeEntry || Object.entries(fieldValue?.type || {})[0];
// 如果没有任何条目,则返回空
if (!mainTypeEntry) return null;
const [, mainTypeValue] = mainTypeEntry;
/**
* 创建提示框内容
* 这个函数返回一个React节点,用于在提示框中显示
* 它将为每种规则类型(exists/format/logic/regex)创建一个带有状态标识的项目
*/
const createTooltipContent = () => {
return (
<div className="flex flex-row gap-2 overflow-x-auto max-h-[300px]">
{Object.entries(fieldValue?.type || {}).map(([typeKey, typeValue]) => (
<div key={typeKey} className={`rounded-md flex flex-row items-center`}>
<div className="text-xs text-gray-600 pl-1 whitespace-nowrap">{getRuleTypeText(typeKey)}:</div>
<div className={`p-1 text-xs rounded-full min-w-[50px] text-center`}>
{typeValue.res ? '通过' : '不通过'}
</div>
</div>
))}
</div>
);
};
/**
* 处理鼠标悬停事件
* 当鼠标悬停在状态指示器上时,计算提示框应该显示的位置并显示提示框
* @param e 鼠标事件对象
*/
const handleMouseEnter = (e: React.MouseEvent): void => {
// 获取触发元素的位置信息
const rect = e.currentTarget.getBoundingClientRect();
// 调用全局函数显示提示框,传递内容和位置信息
showTooltip(
createTooltipContent(),
{ top: rect.top + rect.height/2, left: rect.left }
);
};
return (
<button
className={`border border-gray rounded-md overflow-hidden mb-2 ${overallResult ? 'bg-[rgba(246,255,237,1)]' : 'bg-[rgba(255,251,230,1)]'} flex w-full text-left
hover:shadow-[0_0_10px_rgba(0,0,0,0.1)] ${overallResult ? 'hover:bg-[rgba(0,128,0,0.1)]' : 'hover:bg-[rgba(255,255,0,0.1)]'}`}
onClick={(e) => {
e.stopPropagation();
if (mainTypeValue.page && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), resolveCharPositions(mainTypeValue), mainTypeValue.value);
}else if(reviewPoint.contentPage && reviewPoint.contentPage[fieldKey]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[fieldKey]), resolveCharPositions(mainTypeValue), mainTypeValue.value);
}else{
toastService.error(`没有找到${fieldKey}对应的索引内容`);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (mainTypeValue.page && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPoint.id, Number(mainTypeValue.page), resolveCharPositions(mainTypeValue), mainTypeValue.value);
}else if(reviewPoint.contentPage && reviewPoint.contentPage[fieldKey]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[fieldKey]), resolveCharPositions(mainTypeValue), mainTypeValue.value);
}else{
toastService.error(`没有找到${fieldKey}对应的索引内容`);
}
}
}}
type="button"
aria-label={`查看${fieldKey}内容详情`}
>
<div className="p-1 flex-1">
{/* 字段名称 */}
<div className="text-xs text-gray-500 mb-1">
{fieldKey}
{!mainTypeValue.page && (reviewPoint.contentPage && !reviewPoint.contentPage[fieldKey]) && (
<i className="ri-information-line text-red-500 text-xs ml-1" title="没有找到对应的文书内容"></i>
)}
{/* 缺失显示 */}
{mainTypeValue.res === false && !mainTypeValue.value && (
<span className="ml-2 text-xs text-yellow-500">
</span>
)}
</div>
{/* 主要值显示 */}
{mainTypeValue.value && (
<ReactTableTooltip content={mainTypeValue.value} />
)}
</div>
{/* 状态指示器 - 绑定鼠标事件用于显示/隐藏提示框 */}
<div
className="w-8 flex items-center justify-center rounded-r-md"
onMouseEnter={handleMouseEnter} // 鼠标进入时显示提示框
onMouseLeave={hideTooltip} // 鼠标离开时隐藏提示框
>
{overallResult ? (
<i className="ri-check-line text-success text-base hover:text-green-800" ></i>
) : (
<i className="ri-alert-line text-warning text-base hover:text-yellow-800" ></i>
)}
</div>
</button>
);
};
/**
* 渲染评查点大模型判断的规则的样式
*
* 该函数处理AI模型评估的结果展示,包括:
* 1. 从规则配置中提取字段和评估结果
* 2. 为每个字段创建可点击的UI元素,显示内容和评估状态
* 3. 展示模型的评估消息
* 4. 处理字段点击导航到相应页面的逻辑
*
* @param aiRule 评查点大模型判断的规则对象
* @param reviewPoint 关联的评查点对象
* @returns React组件,用于显示AI模型评估结果
*/
const renderModelRule = (aiRule: Record<string, unknown>, reviewPoint: ReviewPoint) => {
// 从aiRule中提取配置信息
const config = aiRule.config as {
model?: string;
fields?: Record<string, {
page: number | string;
value: string;
res: boolean;
char_positions?: CharPosition[];
}>;
ai_suggestion?: {
summary?: string;
analysis?: {
failure_reason?: string;
solution_approach?: string;
rule_understanding?: string;
};
suggestions?: Record<string, {
reason: string;
source: {
page: number | null;
type: string;
field: string | null;
};
priority: string;
confidence: number;
suggested_value: string | null;
}>;
generated_at?: string;
};
message?: string;
res?: boolean;
} | undefined;
// 如果评查点评查结果和规则的结果不一致,则不渲染,跳过
if(config?.res !== reviewPoint.result){
return null;
}
// 如果配置不存在,不渲染任何内容
if (!config) return null;
// 获取第一个有效页码
if (reviewPoint.id && !effectivePages[reviewPoint.id] && config.fields) {
for (const field of Object.values(config.fields || {})) {
if (field.page && Number(field.page) > 0) {
setEffectivePages(prev => ({
...prev,
[reviewPoint.id || '']: Number(field.page)
}));
break;
}
}
}
// 创建一个数组来存储需要渲染的JSX元素
const fieldElements: JSX.Element[] = [];
// 遍历fields,获取每个字段的值并生成对应的JSX元素
if (config.fields) {
Object.entries(config.fields).forEach(([key, value], index) => {
const res = value.value.trim() !== '';
fieldElements.push(
<button
key={`field-${index}`}
className={`border border-gray w-full
rounded-md overflow-hidden mb-2 ${value.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)] ${value.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), resolveCharPositions(value), value.value);
}else if(reviewPoint.contentPage && reviewPoint.contentPage[key]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[key]), resolveCharPositions(value), value.value);
}else{
toastService.error(`没有找到${key}对应的索引内容`);
}
}}
onKeyDown={(e) => {
// 键盘导航支持
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (value.page && typeof onReviewPointSelect === 'function') {
onReviewPointSelect(reviewPoint.id, Number(value.page), resolveCharPositions(value), value.value);
}else if(reviewPoint.contentPage && reviewPoint.contentPage[key]){
onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[key]), resolveCharPositions(value), value.value);
}else{
toastService.error(`没有找到${key}对应的索引内容`);
}
}
}}
type="button"
aria-label={`查看${key}内容详情`}
>
<div className="p-1 flex-1 text-left">
{/* 字段名称 */}
<div className="text-xs text-left text-gray-500 mb-1">
{key}
{/* 没有抽取到目录内容,page和value都为空 */}
{!value.page && (reviewPoint.contentPage && !reviewPoint.contentPage[key]) && (
<i className="ri-information-line text-red-500 text-xs ml-1" title="没有找到对应的文书内容"></i>
)}
{/* 缺失显示 */}
{!res && (
<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`}>
{value.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`}>
{ value.res ? '通过' : '不通过'}
</div>
</div>
</div>
);
// 显示提示框,稍微向下偏移,便于鼠标移动到tooltip上
showTooltip(content, {
top: rect.top + rect.height/2,
left: rect.left
});
}}
onMouseLeave={hideTooltip}
/>
</div>
</button>
);
});
}
// 渲染AI模型返回的评估消息
if (config.message) {
// 检查message是否为对象,如果是则转换为字符串
const messageContent = typeof config.message === 'object'
? JSON.stringify(config.message)
: String(config.message);
// 添加模型评估消息区域,使用蓝色背景突出显示
fieldElements.push(
<div key="message" className="p-2 bg-blue-50 rounded border border-blue-200 text-xs mb-3 select-text">
<div className="flex flex-row items-center">
<i className="ri-robot-2-line text-blue-500 mr-2"></i>
<p className="text-xs text-gray-600 select-text mb-0">{messageContent}</p>
</div>
</div>
);
}
// 渲染AI建议(ai_suggestion
if (config.ai_suggestion?.suggestions && Object.keys(config.ai_suggestion.suggestions).length > 0) {
// 判断是否为PDF文档(禁用替换按钮)
fileFormat = fileFormat?.replace(/\./g,'')
const isPDF = fileFormat?.toUpperCase() === 'PDF';
// 遍历suggestions对象的key-value对
Object.entries(config.ai_suggestion.suggestions).forEach(([key, suggestionValue], index) => {
// 检查建议值是否存在(null 或有值都要渲染)
const hasSuggestedValue = suggestionValue.suggested_value !== null && suggestionValue.suggested_value.trim() !== '';
const isReplaceDisabled = !hasSuggestedValue || isPDF;
fieldElements.push(
<div key={`ai-suggestion-${index}`} className="mb-3">
{/* 字段名称标签 */}
<div className="text-xs text-gray-600 mb-2 font-medium">
<i className="ri-lightbulb-line text-yellow-500 mr-1"></i>
AI建议修改 - {key}
</div>
{/* 原因说明 */}
<div className="mb-2 p-2 bg-amber-50 rounded border border-amber-200 text-xs text-gray-700">
<div className="flex items-center">
<i className="ri-information-line text-amber-600 mr-1 mt-0.5"></i>
<div>
<span>{suggestionValue.reason}</span>
{suggestionValue.source.page !== null && (
<span className="ml-2 text-gray-500">
(: {suggestionValue.source.page})
</span>
)}
</div>
</div>
</div>
{/* 建议内容和替换按钮 */}
<div className="flex gap-2 items-center">
{/* 文本输入框 */}
<textarea
value={suggestionValue.suggested_value || ''}
readOnly
disabled={!hasSuggestedValue}
rows={Math.min(Math.max(Math.ceil((suggestionValue.suggested_value || '').length / 30), 1), 5)}
className={`flex-1 p-2 border rounded text-xs resize-none overflow-y-auto focus:outline-none ${
hasSuggestedValue
? 'border-gray-200 bg-gray-50 text-gray-700'
: 'border-gray-200 bg-gray-100 text-gray-400'
}`}
aria-label={`${key}的AI建议内容`}
placeholder={!hasSuggestedValue ? '暂无建议值' : ''}
/>
{/* 意见替换按钮 */}
{ !isPDF &&
<button
type="button"
onClick={() => {
if (!isReplaceDisabled && onAiSuggestionReplace && config.fields) {
// 从 config.fields[key] 中获取对应的字段信息
const fieldData = config.fields[key];
if (fieldData) {
// 调用回调函数,传递搜索文本(原文)、替换文本(AI建议)和页码
onAiSuggestionReplace(
fieldData.value || '', // 搜索文本(使用 suggestions 的 key对应的config中的key的value值)
suggestionValue.suggested_value || '', // 替换文本(AI建议的 suggested_value
Number(fieldData.page) || 1 // 页码
);
} else {
toastService.error(`未找到字段 ${key} 的原始数据`);
}
}
}}
disabled={isReplaceDisabled}
className={`px-3 py-2 text-xs rounded whitespace-nowrap transition-colors
${isReplaceDisabled
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-primary text-white hover:bg-primary-hover active:bg-primary'
}`}
title={
isPDF
? 'PDF文档不支持替换'
: !hasSuggestedValue
? '暂无建议值,无法替换'
: '点击执行一键替换'
}
aria-label={`替换${key}的内容`}
>
<i className="ri-exchange-line mr-1"></i>
</button>
}
</div>
</div>
);
});
}
// 返回包含所有元素的React片段
return <>{fieldElements}</>;
};
/**
* 过滤评查点中的规则,把type是exists、format、logic、regex的规则中重复的进行去重和合并
*
* 该函数的主要作用:
* 1. 从评查点的evaluatedPointResultsLog中提取特定类型的规则
* 2. 将相同字段(fieldKey)的不同规则类型结果合并到一起
* 3. 为UI渲染准备统一结构的数据
*
* 支持的规则类型:
* - exists: 有无判断规则
* - format: 格式判断规则
* - logic: 逻辑判断规则
* - regex: 正则表达式规则
*
* @param reviewPoint 评查点对象
* @returns 合并后的规则数组,每个元素包含字段名和各类规则的评估结果
*/
const filterOtherRule = (reviewPoint: ReviewPoint) => {
// 定义接口描述规则字段值的结构
interface RuleFieldValue {
page?: number | string;
value?: string;
char_positions?: CharPosition[];
type: Record<string, boolean>;
}
const allRule: Array<{
fieldKey: string;
fieldValue: RuleFieldValue;
}> = [];
for (const rule of reviewPoint.evaluatedPointResultsLog?.rules || []) {
// 如果评查点评查结果和规则的结果不一致,则不渲染,跳过
if(rule.config.res !== reviewPoint.result){
continue;
}
// 处理"有无判断"类型的规则
if (rule.type === 'exists') {
// 使用类型断言获取config对象的具体结构
const config = rule.config as {
res: boolean;
fields: Record<string, { page: number; value: string; char_positions?: CharPosition[] }>;
logic?: string;
};
// 如果res为true,则遍历fields,提取不为空的字段
if (config.res) {
// 遍历fields对象的每个属性
Object.entries(config.fields).forEach(([key, fieldValue]) => {
// 只处理值不为空的字段
if (fieldValue.value && fieldValue.value.trim() !== '') {
// 创建新对象并添加type标记
const newItem = {
fieldKey: key,
fieldValue: {
...fieldValue,
type: { exists: true }
}
};
allRule.push(newItem);
}
});
} else {
// 如果res为false,则遍历fields,提取所有字段
Object.entries(config.fields).forEach(([key, fieldValue]) => {
// 根据值是否为空添加不同的type标记
const isValueEmpty = !fieldValue.value || fieldValue.value.trim() === '';
// 创建新对象并添加type标记
const newItem = {
fieldKey: key,
fieldValue: {
...fieldValue,
type: { exists: isValueEmpty ? false : true }
}
};
allRule.push(newItem);
});
}
}
// 处理"格式判断"类型的规则
if (rule.type === 'format') {
// 使用类型断言获取config对象的具体结构
const config = rule.config as {
res: boolean;
field: Record<string, { page: string | number; value: string; char_positions?: CharPosition[]}>;
formatType?: string;
parameters?: string;
};
// 从config中获取field对象
// 注意:根据示例,format类型中是field而不是fields
if (config.field) {
// 获取field中唯一的键值对
const entries = Object.entries(config.field);
if (entries.length > 0) {
const [key, fieldValue] = entries[0];
// 创建新对象并添加type标记
const newItem = {
fieldKey: key,
fieldValue: {
...fieldValue,
type: { format: config.res } // 标记为format类型,结果为config.res
}
};
allRule.push(newItem);
}
}
}
// 处理"逻辑判断"类型的规则
if (rule.type === 'logic') {
// 使用类型断言获取config对象的具体结构
const config = rule.config as {
logic: string;
res: boolean;
conditions: Array<{
field: Record<string, { page: number | string; value: string; char_positions?: CharPosition[] }>;
value: string;
operator: string;
res: boolean;
}>;
};
// 遍历conditions数组
if (config.conditions && Array.isArray(config.conditions)) {
config.conditions.forEach(condition => {
// 从condition中获取field对象
const entries = Object.entries(condition.field);
if (entries.length > 0) {
const [key, fieldValue] = entries[0];
// 创建新对象并添加type标记
const newItem = {
fieldKey: key,
fieldValue: {
...fieldValue,
type: { logic: condition.res }
}
};
allRule.push(newItem);
}
});
}
}
// 处理"正则表达式"类型的规则
if (rule.type === 'regex') {
// 使用类型断言获取config对象的具体结构
const config = rule.config as {
res: boolean;
field: Record<string, { page: number | string; value: string; char_positions?: CharPosition[] }>;
pattern?: string;
matchType?: string;
selectedFields?: string[];
};
if (config.field) {
const entries = Object.entries(config.field);
if (entries.length > 0) {
const [key, fieldValue] = entries[0];
// 创建新对象并添加type标记
const newItem = {
fieldKey: key,
fieldValue: {
...fieldValue,
type: { regex: config.res }
}
};
allRule.push(newItem);
}
}
}
}
// console.log('allRule-------', allRule);
// 对allRule进行去重和合并
const mergedRules: Array<{
fieldKey: string;
fieldValue: {
type: Record<string, {
res: boolean;
page?: number | string;
value?: string;
char_positions?: CharPosition[]
}>;
};
}> = [];
// 使用对象存储相同fieldKey的项,便于快速查找和合并
const fieldKeyMap: Record<string, {
fieldKey: string;
fieldValue: {
type: Record<string, {
res: boolean;
page?: number | string;
value?: string;
char_positions?: CharPosition[]
}>;
};
}> = {};
// 第一步:按fieldKey分组并合并不同类型的规则结果
allRule.forEach(item => {
const fieldKey = item.fieldKey;
const fieldValue = item.fieldValue;
const typeKey = Object.keys(fieldValue.type)[0]; // 获取类型名称(exists/logic/regex/format
const typeValue = fieldValue.type[typeKey]; // 获取类型值(true/false
// 提取页码和值和字符位置
const page = fieldValue.page;
const value = fieldValue.value;
const char_positions = fieldValue.char_positions
// 如果是第一次遇到这个fieldKey,创建新条目
if (!fieldKeyMap[fieldKey]) {
// 创建新的结构
fieldKeyMap[fieldKey] = {
fieldKey,
fieldValue: {
type: {}
}
};
}
// 将类型信息添加到type对象中,允许一个字段有多种规则类型的结果
fieldKeyMap[fieldKey].fieldValue.type[typeKey] = {
res: typeValue,
page,
value,
char_positions
};
});
// 将合并后的对象转换为数组
for (const key in fieldKeyMap) {
mergedRules.push(fieldKeyMap[key]);
}
// 获取第一个有效页码
if (reviewPoint.id && !effectivePages[reviewPoint.id]) {
// 遍历合并后的规则数组,查找第一个有效页码
for (const rule of mergedRules) {
// 遍历字段类型对象
const typeEntries = Object.entries(rule.fieldValue.type);
// 遍历每种类型规则
for (const [, typeValue] of typeEntries) {
// 检查是否有有效页码
if (typeValue.page && Number(typeValue.page) > 0) {
// 找到有效页码,设置状态并跳出循环
setEffectivePages(prev => ({
...prev,
[reviewPoint.id || '']: Number(typeValue.page)
}));
// 使用break跳出当前循环
break;
}
}
// 如果已经找到有效页码,跳出外层循环
if (reviewPoint.id && effectivePages[reviewPoint.id]) {
break;
}
}
}
// 返回合并后的规则数组
return mergedRules;
};
/**
* 渲染评查点内容与建议
* @param reviewPoint 评查点
* @returns 评查点内容与建议组件
*/
const renderReviewPointContent = (reviewPoint: ReviewPoint) => {
const mergedRules = filterOtherRule(reviewPoint);
// console.log('mergedRules1-------', mergedRules);
// 根据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="bg-[#f0f9ff] border border-[#bae0ff] rounded p-3 mb-3 select-text">
{reviewPoint.legalBasis.name && (
<div className="text-[#0958d9] font-medium text-sm mb-2">{reviewPoint.legalBasis.name}</div>
)}
{reviewPoint.legalBasis.articles && Array.isArray(reviewPoint.legalBasis.articles) && reviewPoint.legalBasis.articles.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{reviewPoint.legalBasis.articles.map((item, index) => (
<span key={index} className="bg-[#e6f4ff] border border-[#91caff] rounded-full px-2 py-0.5 text-xs text-[#0958d9]">
{typeof item === 'string' ? item :
typeof item === 'object' && item !== null ?
(item.name || item.content || JSON.stringify(item)) :
String(item)}
</span>
))}
</div>
)}
{reviewPoint.legalBasis.content && (
<div className="text-[13px] text-[#434343] leading-relaxed border-l-[3px] border-[#bae0ff] pl-2 mt-2">{reviewPoint.legalBasis.content}</div>
)}
</div>
)
)}
{reviewPoint.content !== null && Object.keys(reviewPoint.content).length > 0 && (
<>
{/* 内容显示区域 */}
<div className="bg-white rounded border-gray-200 text-xs mb-3 select-text">
<div>
{/* 修改评查结果的结构之后,显示新的结构 */}
{renderContent(reviewPoint, mergedRules)}
</div>
</div>
</>
)}
</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">
{/* 悬浮的意见数量显示 - 固定在左侧,需要 canReadProposal 权限 */}
{canReadProposal && (
<button
className="absolute left-[-35px] top-16 z-10 cursor-pointer"
onClick={() => handleOpenOpinionListModal(reviewPoints[0])}
type="button"
aria-label="查看意见列表"
>
<div className={`relative flex flex-col items-center bg-gradient-to-br from-blue-50 to-blue-100 px-2 py-2 rounded-lg border border-blue-300 shadow-md transition-all duration-200 ease-out hover:scale-110 hover:shadow-xl active:scale-95 ${scoringProposals.length === 0 ? 'opacity-50' : 'opacity-100'}`}>
{/* 脉动提示点 - 仅当有意见时显示 */}
{scoringProposals.length > 0 && (
<span className="absolute -top-1 -right-1 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-blue-500"></span>
</span>
)}
<i className="ri-chat-1-line text-blue-600 text-lg mb-0.5"></i>
<span className="text-lg text-blue-700 font-bold leading-tight">{scoringProposals.length}</span>
<div className="flex flex-col items-center text-[10px] text-blue-600 leading-tight mt-0.5">
<span></span>
<span></span>
<span></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="flex items-center gap-2 max-w-[75%]">
<div className="review-point-title text-left text-blue-500 break-all">{reviewPoint.pointName}</div>
{ reviewPoint.pointName === '签署乙方详细信息校验' && (() => {
const firstContentKey = reviewPoint.content ? Object.keys(reviewPoint.content)[0] : undefined;
const firstContentValue = firstContentKey ? reviewPoint.content![firstContentKey]?.value : undefined;
return (
<button
className="enterprise-info-btn"
style={{
padding: '2px 8px',
fontSize: '12px',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
gap: '4px',
flexShrink: 0,
transition: 'all 0.2s',
border: 'none',
cursor: firstContentValue ? 'pointer' : 'not-allowed',
backgroundColor: firstContentValue ? '#00684a' : '#e5e7eb',
color: firstContentValue ? '#ffffff' : '#9ca3af',
}}
disabled={!firstContentValue}
onClick={(e) => {
e.stopPropagation();
const companyName = typeof firstContentValue === 'string' ? firstContentValue : String(firstContentValue || '');
if (companyName) {
handleCorporateInfoClick(companyName);
}
}}
onMouseEnter={(e) => {
if (firstContentValue) {
e.currentTarget.style.backgroundColor = '#005a3f';
}
}}
onMouseLeave={(e) => {
if (firstContentValue) {
e.currentTarget.style.backgroundColor = '#00684a';
}
}}
>
<i className="ri-eye-line"></i>
</button>
);
})()}
{ reviewPoint.pointName === '签署甲方详细信息校验' && (() => {
const firstContentKey = reviewPoint.content ? Object.keys(reviewPoint.content)[0] : undefined;
const firstContentValue = firstContentKey ? reviewPoint.content![firstContentKey]?.value : undefined;
return (
<button
className="enterprise-info-btn"
style={{
padding: '2px 8px',
fontSize: '12px',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
gap: '4px',
flexShrink: 0,
transition: 'all 0.2s',
border: 'none',
cursor: firstContentValue ? 'pointer' : 'not-allowed',
backgroundColor: firstContentValue ? '#00684a' : '#e5e7eb',
color: firstContentValue ? '#ffffff' : '#9ca3af',
}}
disabled={!firstContentValue}
onClick={(e) => {
e.stopPropagation();
const companyName = typeof firstContentValue === 'string' ? firstContentValue : String(firstContentValue || '');
if (companyName) {
handleCorporateInfoClick(companyName);
}
}}
onMouseEnter={(e) => {
if (firstContentValue) {
e.currentTarget.style.backgroundColor = '#005a3f';
}
}}
onMouseLeave={(e) => {
if (firstContentValue) {
e.currentTarget.style.backgroundColor = '#00684a';
}
}}
>
<i className="ri-eye-line"></i>
</button>
);
})()}
</div>
{/* <div className="review-point-header flex justify-between items-start">
<div className="flex-1 text-left min-w-[25%] font-medium text-[13px]">{reviewPoint.title}</div>
//评查点分组显示
<div className="review-point-location max-w-[40%] flex items-center">
<i className="ri-file-list-line mr-1 flex-shrink-0"></i>
<span
className="truncate block whitespace-nowrap overflow-hidden
hover:overflow-visible hover:text-clip hover:bg-white hover:shadow-md hover:z-10 hover:text-wrap px-1 rounded"
title={reviewPoint.groupName}
style={{ cursor: 'text', userSelect: 'all' }}
>
{reviewPoint.groupName}
</span>
</div>
</div> */}
{/* </div> */}
<div className="flex ml-2 flex-shrink-0 min-w-[15%]">
{/* 提出意见按钮 - 需要 canCreateProposal 权限 */}
{canCreateProposal && (
<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>
) : (
<>
<div style={{ minHeight: '500px', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, minHeight: '450px' }}>
<Table
columns={[
{
title: "评查点名称",
key: "evaluation_point_name",
width: "15%",
render: (_: unknown, record: CrossCheckingOpinion) => (
<div className="text-sm text-left py-1">{record.evaluation_point_name}</div>
)
},
{
title: "问题描述",
key: "problem_message",
width: "18%",
render: (_: unknown, record: CrossCheckingOpinion) => (
<div className="text-sm text-left py-1">{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 (
<div className="text-sm text-left py-1" title={reason}>{display}</div>
);
}
},
{
title: "调整分数",
key: "proposed_score",
width: "8%",
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: "24%",
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 items-start gap-1.5 py-1 w-full">
{voterGroups.map((group) => (
Array.isArray(group.voters) && group.voters.length > 0 && (
<div key={group.type} className="flex items-start gap-1 w-full">
{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
transition-all hover:scale-[1.03] hover:shadow-sm
`}
title={name}
>
{name}
</span>
))}
</div>
)
))}
</div>
);
}
},
{
title: "意见发起人",
key: "proposer",
width: "8%",
render: (_: unknown, record: CrossCheckingOpinion) => (
<div className="flex items-center justify-center py-1">
<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 transition-all hover:scale-[1.03] hover:shadow-sm"
title={record.proposer}
>
{record.proposer}
</span>
</div>
)
},
{
title: "发起时间",
key: "created_at",
width: "8%",
render: (_: unknown, record: CrossCheckingOpinion) => (
<div className="text-sm text-left py-1">{record.created_at}</div>
)
},
{
title: "投票状态",
key: "opinion_status",
width: "12%",
align: "center" as const,
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={`text-sm 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}
canVoteProposal={canVoteProposal}
canDeleteProposal={canDeleteProposal}
/>
);
}
}
]}
dataSource={opinionListData}
rowKey="proposal_id"
emptyText="暂无意见数据"
className="opinion-list-table"
/>
</div>
{/* 分页组件 */}
{opinionListTotal > 0 && (
<Pagination
currentPage={opinionListCurrentPage}
total={opinionListTotal}
pageSize={opinionListPageSize}
onChange={handleOpinionListPageChange}
onPageSizeChange={handleOpinionListPageSizeChange}
showTotal={true}
showPageSizeChanger={true}
pageSizeOptions={[5,10, 20, 30, 50]}
/>
)}
</div>
</>
)}
</div>
</Modal>
{/* 企业信息模态框 */}
<CorporateInfoModal
visible={corporateModalVisible}
onClose={handleCloseCorporateModal}
companyName={corporateCompanyName}
businessInfo={corporateBusinessInfo}
dishonestyInfo={corporateDishonestyInfo}
businessLoading={corporateLoading}
dishonestyLoading={corporateLoading}
businessError={corporateError}
dishonestyError={corporateError}
updatedAt={corporateUpdatedAt}
onForceRefresh={handleCorporateForceRefresh}
/>
</div>
</>
);
}
// 操作按钮区美化+弹窗确认组件
function OpinionActions({ record, isPerforming, handleOpinionAction, userInfo, canVoteProposal = true, canDeleteProposal = true }: {
record: CrossCheckingOpinion;
isPerforming: (action: string) => boolean;
handleOpinionAction: (id: string | number, action: OpinionActionType) => void;
userInfo?: { user_id: number };
canVoteProposal?: boolean; // 赞同/反对/撤销投票权限
canDeleteProposal?: boolean; // 撤销意见权限
}) {
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 === Number(userInfo.user_id);
return (
<div className="flex gap-3">
{/* 仅当can_vote为true且有canVoteProposal权限时显示赞同/反对按钮 */}
{record.can_vote && canVoteProposal && (
<>
<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且有canVoteProposal权限时显示撤销投票按钮 */}
{!record.can_vote && !isProposer && canVoteProposal && (
<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>
)}
{/* 仅当是发起人且有canDeleteProposal权限才显示撤销意见按钮 */}
{isProposer && canDeleteProposal && (
<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>
);
}