优化交叉评查详情页面

This commit is contained in:
2025-07-16 22:20:02 +08:00
parent 328f326db3
commit 4843b7bebf
19 changed files with 4955 additions and 4 deletions
+130
View File
@@ -0,0 +1,130 @@
import { postgrestPost } from "../postgrest-client";
import { API_BASE_URL } from "../../config/api-config";
/**
* 提出意见的请求参数接口
*/
export interface SubmitOpinionRequest {
reviewPointResultId: string | number;
documentId: string | number;
auditPoint: string;
foundIssue: string;
auditOpinion: string;
deductionScore: number;
}
/**
* 提出意见的响应接口
*/
export interface SubmitOpinionResponse {
success: boolean;
message: string;
data?: {
id: string | number;
created_at: string;
};
}
/**
* 交叉评查意见数据接口
*/
export interface CrossCheckingOpinion {
id: string | number;
evaluation_point_id: string | number;
document_id: string | number;
audit_point: string;
found_issue: string;
audit_opinion: string;
deduction_score: number;
status: string;
created_at: string;
updated_at?: string;
}
/**
* API响应格式
*/
export interface ApiResponse<T> {
data?: T;
error?: string;
status?: number;
}
/**
* 提交交叉评查意见
* @param opinionData 意见数据
* @returns 提交结果
*/
export async function submitCrossCheckingOpinion(
opinionData: SubmitOpinionRequest
): Promise<ApiResponse<SubmitOpinionResponse>> {
try {
const requestData = {
proposer_user_id: 1,
evaluation_result_id: opinionData.reviewPointResultId,
// document_id: opinionData.documentId,
// audit_point: opinionData.auditPoint,
// found_issue: opinionData.foundIssue,
proposed_score: opinionData.deductionScore,
reason: opinionData.auditOpinion
};
const response = await fetch(`${API_BASE_URL}/admin/cross_review/proposals`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestData)
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || '提交失败');
}
return {
data: {
success: true,
message: '意见提交成功',
data: data
}
};
} catch (error) {
console.error('提交交叉评查意见失败:', error);
return {
error: error instanceof Error ? error.message : '提交意见失败',
status: 500
};
}
}
/**
* 获取交叉评查意见列表
* @param documentId 文档ID
* @returns 意见列表
*/
export async function getCrossCheckingOpinions(documentId: string | number): Promise<ApiResponse<CrossCheckingOpinion[]>> {
try {
const response = await postgrestPost('rpc/get_cross_checking_opinions', {
p_document_id: documentId
});
if (response.error) {
return {
error: response.error,
status: response.status || 500
};
}
return {
data: (response.data as CrossCheckingOpinion[]) || []
};
} catch (error) {
console.error('获取交叉评查意见失败:', error);
return {
error: error instanceof Error ? error.message : '获取意见列表失败',
status: 500
};
}
}
+39 -2
View File
@@ -109,6 +109,19 @@ interface ContractStructureComparison {
[key: string]: unknown;
}
// 定义评分提案数据接口
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;
}
/**
* 获取当前评查文件的所有评查点结果
* @param fileId 评查文件ID
@@ -294,6 +307,26 @@ export async function getReviewPoints(fileId: string) {
// console.log('groupsMap-------', groupsMap);
//从scoring_proposals表中获取评分提案数据,用于交叉评查
const scoringProposalsParams: PostgrestParams = {
select: '*',
filter: {
'document_id': `eq.${fileId}`
}
};
const scoringProposalsResponse = await postgrestGet('scoring_proposals', scoringProposalsParams);
if (scoringProposalsResponse.error) {
return { error: scoringProposalsResponse.error, status: scoringProposalsResponse.status };
}
const scoringProposalsData = extractApiData<ScoringProposal[]>(scoringProposalsResponse.data) || [];
// 构建前端所需的数据格式
const resultData: ReviewPointResult[] = evaluationResultsData.map(result => {
const point = pointsMap.get(result.evaluation_point_id) || {} as EvaluationPoint;
@@ -394,6 +427,10 @@ export async function getReviewPoints(fileId: string) {
// 评查配置: point.evaluation_config
evaluationConfig: point.evaluation_config || {},
// 评查点evaluation_point中的fail_message和pass_message 用于交叉评查的提出意见
failMessage: point.fail_message || '',
passMessage: point.pass_message || '',
evaluatedPointResultsLog: evaluatedPointResultsLog || {}
// evaluatedPointResultsLog: {
// rules:[
@@ -671,8 +708,8 @@ export async function getReviewPoints(fileId: string) {
issueCount: issueCount
};
// console.log("reviewInfo-------",JSON.stringify(reviewInfo,null,2));
// data->reviewPoints stats->statistics reviewInfo->reviewInfo document->document
return { data: resultData, stats, reviewInfo, document: documentData.data, comparison_document: comparisonDocument };
// data->reviewPoints stats->statistics reviewInfo->reviewInfo document->document scoring_proposals->scoringProposalsData
return { data: resultData, stats, reviewInfo, document: documentData.data, comparison_document: comparisonDocument, scoring_proposals: scoringProposalsData };
}
/**
@@ -0,0 +1,91 @@
/**
* 交叉评查文件信息组件
*/
interface FileInfoProps {
fileInfo: {
fileName: string;
contractNumber: string;
fileSize?: string;
fileFormat?: string;
pageCount?: number;
uploadTime?: string;
uploadUser?: string;
auditStatus?: number;
path?: string;
previousRoute?: string;
fileType?: string;
};
onConfirmResults: () => void;
}
export function FileInfo({ fileInfo, onConfirmResults }: FileInfoProps) {
const handleDownloadFile = () => {
if (fileInfo.path) {
// 创建一个隐藏的下载链接
const link = document.createElement('a');
link.href = fileInfo.path;
link.download = fileInfo.fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
alert('文件路径不存在,无法下载');
}
};
const handleExportReport = () => {
alert('导出交叉评查报告功能');
};
return (
<div className="mb-4 file-info-header">
<div className="flex justify-between items-center">
<div className="flex-1">
{/* 文件基本信息已在面包屑区域显示,这里可以不重复显示 */}
</div>
{/* 操作按钮区域 */}
<div className="flex items-center gap-3">
{/* 下载原文件按钮 */}
<button
onClick={handleDownloadFile}
className="inline-flex items-center px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<i className="fas fa-download mr-1.5"></i>
</button>
{/* 导出评查报告按钮 */}
<button
onClick={handleExportReport}
className="inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<i className="fas fa-file-export mr-1.5"></i>
</button>
{/* 确认评查结果按钮 - 只在未审核通过时显示 */}
{fileInfo.auditStatus !== 1 && (
<button
onClick={onConfirmResults}
className="inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-green-600 border border-transparent rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
<i className="fas fa-check mr-1.5"></i>
</button>
)}
{/* 已确认状态显示 */}
{fileInfo.auditStatus === 1 && (
<span className="inline-flex items-center px-3 py-1.5 text-sm font-medium text-green-700 bg-green-100 border border-green-200 rounded-md">
<i className="fas fa-check-circle mr-1.5"></i>
</span>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,599 @@
/**
* 文件预览组件
* 显示文档内容和评查点高亮
*/
import { useState, useEffect, useRef, ChangeEvent } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import { DOCUMENT_URL } from '~/api/axios-client';
// 设置worker路径为public目录下的worker文件
// 使用已经下载的兼容版本 (pdfjs-dist v2.12.313)
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
// 导入统一的ReviewPoint类型
import { type ReviewPoint } from './';
import { toastService } from '../ui/Toast';
/**
* 自定义样式
* 这些样式解决了PDF页面在放大时互相重叠的问题
*/
const styles = {
pdfContainer: {
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
width: '100%',
position: 'relative' as const,
},
pageContainer: {
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
width: '100%',
position: 'relative' as const,
}
};
// 定义文档内容类型
interface FileContent {
title: string;
contractNumber: string;
path: string;
ocrResult?: {
__meta?: {
page_offset?: number;
};
}; // 添加ocrResult属性
parties: {
partyA: {
name: string;
address: string;
representative: string;
phone: string;
};
partyB: {
name: string;
address: string;
representative: string;
phone: string;
};
};
sections: {
title: string;
content: string;
}[];
template_contract_path?: string;
}
interface FilePreviewProps {
fileContent: FileContent;
reviewPoints?: ReviewPoint[]; // 设为可选
activeReviewPointResultId: string | null;
targetPage?: number; // 新增目标页码参数
isStructuredView?: boolean; // 是否显示结构化视图
}
// export function FilePreview({ fileContent, reviewPoints, activeReviewPointResultId, targetPage }: FilePreviewProps) {
export function FilePreview({ fileContent, activeReviewPointResultId, targetPage, isStructuredView = false }: FilePreviewProps) {
const [zoomLevel, setZoomLevel] = useState(100);
// const [highlightsVisible, setHighlightsVisible] = useState(true);
const contentRef = useRef<HTMLDivElement>(null);
const [numPages, setNumPages] = useState<number | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [pageInputValue, setPageInputValue] = useState<string>('');
// 拖拽状态管理
const [dragMode, setDragMode] = useState(false); // 是否处于拖拽模式
const [isDragging, setIsDragging] = useState(false);
const [dragCursor, setDragCursor] = useState('default');
const lastMousePosRef = useRef({ x: 0, y: 0 });
// 放大文档
const handleZoomIn = () => {
if (zoomLevel < 200) {
setZoomLevel(prevZoom => prevZoom + 10);
}
};
// 缩小文档
const handleZoomOut = () => {
if (zoomLevel > 50) {
setZoomLevel(prevZoom => prevZoom - 10);
}
};
// 切换拖拽模式
const toggleDragMode = () => {
setDragMode(prev => !prev);
setDragCursor(prev => prev === 'default' ? 'grab' : 'default');
setIsDragging(false);
};
// 处理拖拽开始
const handleMouseDown = (e: React.MouseEvent<HTMLButtonElement>) => {
if (!dragMode || e.button !== 0) return; // 只在拖拽模式下响应左键点击
// 防止选中文本
e.preventDefault();
// 设置拖拽状态
setIsDragging(true);
setDragCursor('grabbing');
// 记录鼠标初始位置
lastMousePosRef.current = {
x: e.clientX,
y: e.clientY
};
};
// 处理拖拽过程
const handleMouseMove = (e: React.MouseEvent<HTMLButtonElement>) => {
if (!dragMode || !isDragging || !contentRef.current) return;
// 计算鼠标移动距离
const dx = e.clientX - lastMousePosRef.current.x;
const dy = e.clientY - lastMousePosRef.current.y;
// 更新容器滚动位置
contentRef.current.scrollLeft -= dx;
contentRef.current.scrollTop -= dy;
// 更新鼠标位置记录
lastMousePosRef.current = {
x: e.clientX,
y: e.clientY
};
};
// 处理拖拽结束
const handleMouseUp = () => {
if (!dragMode) return;
setIsDragging(false);
setDragCursor('grab');
};
// 监听鼠标离开窗口事件
useEffect(() => {
const handleMouseLeave = () => {
if (dragMode && isDragging) {
setIsDragging(false);
setDragCursor('grab');
}
};
document.addEventListener('mouseleave', handleMouseLeave);
document.addEventListener('mouseup', handleMouseUp as EventListener);
return () => {
document.removeEventListener('mouseleave', handleMouseLeave);
document.removeEventListener('mouseup', handleMouseUp as EventListener);
};
}, [isDragging, dragMode]);
// 处理页面跳转
const prevTargetPageRef = useRef<number | undefined>(undefined);
useEffect(() => {
// 调试信息:记录组件状态
// console.log(`FilePreview更新 - isStructuredView:${isStructuredView}, targetPage:${targetPage}, activeReviewPointResultId:${activeReviewPointResultId}, numPages:${numPages}`);
// 如果有目标页码,并且与上次相同,提示用户
if(targetPage && numPages && targetPage <= numPages && targetPage === prevTargetPageRef.current){
// toastService.success(`已跳转至目标页码`);
}
// 如果有目标页码,并且与上次不同或activeReviewPointId变化了,则执行跳转
if (targetPage && numPages && targetPage <= numPages) {
// if (targetPage && numPages && targetPage <= numPages && (targetPage !== prevTargetPageRef.current || activeReviewPointResultId)) {
prevTargetPageRef.current = targetPage;
let newTargetPage = targetPage;
// 页码偏移量
try {
// 安全地访问ocrResult
if (fileContent.ocrResult && fileContent.ocrResult.__meta && fileContent.ocrResult.__meta.page_offset) {
// 可以根据需要使用page_offset调整目标页面
newTargetPage = targetPage + fileContent.ocrResult.__meta.page_offset;
}
} catch (error) {
console.error("访问ocrResult时出错:", error);
toastService.error("访问ocrResult时出错:" + (error instanceof Error ? error.message : '未知错误'));
}
const pageElementId = `page-${newTargetPage}${isStructuredView ? '-structured' : ''}`;
// console.log(`尝试跳转到元素ID: ${pageElementId}`);
const pageElement = document.getElementById(pageElementId);
if (pageElement) {
// console.log(`跳转到第${newTargetPage}页,对应评查点结果ID: ${activeReviewPointResultId}`);
pageElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
console.warn(`未找到页面元素: ${pageElementId}`);
}
}
}, [targetPage, numPages, fileContent, activeReviewPointResultId, isStructuredView]);
// 获取评查点对应的样式类
// const getHighlightClass = (status: string) => {
// switch (status) {
// case 'warning':
// return 'warning';
// case 'error':
// return 'error';
// case 'success':
// return 'success';
// default:
// return 'warning';
// }
// };
// 处理页码输入变化
const handlePageInputChange = (e: ChangeEvent<HTMLInputElement>) => {
// 只允许输入数字
const value = e.target.value.replace(/\D/g, '');
setPageInputValue(value);
};
// 处理页码跳转
const handlePageJump = () => {
if (!pageInputValue || !numPages) return;
const targetPageNum = parseInt(pageInputValue, 10);
// 验证页码是否在有效范围内
if (targetPageNum > 0 && targetPageNum <= numPages) {
// 找到目标页面元素并滚动到该位置
const pageElement = document.getElementById(`page-${targetPageNum}`);
if (pageElement) {
pageElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
} else {
// 页码超出范围,显示错误信息或重置输入
toastService.warning(`请输入有效页码 (1-${numPages})`);
setPageInputValue('');
}
};
// 处理回车键跳转
const handlePageInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handlePageJump();
}
};
// PDF文档加载成功回调函数
function onDocumentLoadSuccess({ numPages }: { numPages: number }) {
setNumPages(numPages);
// console.log("PDF加载成功,页数:", numPages);
}
// 计算页面在缩放后的实际间距
const calculatePageMargin = (zoomFactor: number) => {
// 基础间距为30px,随着缩放倍数线性增加
const baseMargin = 30;
// 页面缩放后,需要额外添加的间距 = (缩放倍数 - 1) * 页面高度
const additionalMargin = Math.max(0, (zoomFactor - 1) * 800); // 800是估计的页面高度
return baseMargin + additionalMargin;
};
// 滚动到顶部
const handleScrollToTop = () => {
if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' });
}
};
/**
* 渲染PDF文档的所有页面
*
* 功能描述:
* 1. 生成PDF所有页面的渲染数组,每个页面包含页码标识和实际页面内容
* 2. 处理页面缩放,通过CSS transform实现页面大小调整
* 3. 在每个页面上标记对应的评查点高亮区域
* 4. 处理评查点的激活状态,显示特殊的高亮效果
*
* @returns {JSX.Element[] | null} 返回所有页面组件的数组,如果没有页数信息则返回null
*/
const renderAllPages = () => {
// 如果还没有获取到PDF总页数,返回null
if (!numPages) return null;
// 用于存储所有页面组件的数组
const pages = [];
// 遍历每一页,生成对应的页面组件
for (let i = 1; i <= numPages; i++) {
// 计算当前缩放级别下的页面容器样式
const zoomFactor = zoomLevel / 100;
const pageContainerStyle = {
...styles.pageContainer,
marginBottom: `${calculatePageMargin(zoomFactor)}px`, // 动态计算页面间距
};
// 为结构化视图和普通视图创建不同的ID
const pageId = isStructuredView ? `page-${i}-structured` : `page-${i}`;
// 为每一页创建组件
pages.push(
<div key={i} id={pageId} style={pageContainerStyle}>
{/* 页码标识,显示在页面上方 */}
<div className="text-center text-gray-500 text-sm mb-2"> {i} </div>
{/* 页面容器,应用缩放变换,设置相对定位用于放置评查点高亮 */}
<div
className="page-wrapper"
style={{
transform: `scale(${zoomFactor})`, // 根据zoomLevel应用缩放
transformOrigin: 'top center', // 缩放原点设置为顶部中心
position: 'relative', // 相对定位,作为评查点高亮的定位参考
display: 'inline-block', // 内联块级元素,宽度由内容决定
margin: '0 auto', // 水平居中
}}
>
{/* 渲染PDF页面组件 */}
<Page
pageNumber={i} // 当前页码
renderTextLayer={true} // 启用文本层,使文本可选择
renderAnnotationLayer={true} // 启用注释层,显示PDF内置注释
className="border border-gray-300 shadow-md" // 添加边框和阴影样式
/>
{/* 渲染评查点高亮区域 */}
{/* {highlightsVisible && pageReviewPoints.map(point => {
// 判断当前评查点是否为激活状态(被选中)
const isActive = point.id === activeReviewPointId;
return (
// 评查点高亮区域
<div
key={point.id}
// 添加多个类名:基础高亮区域、PDF专用高亮、状态样式类、激活状态类
className={`highlight-area pdf-highlight ${getHighlightClass(point.status)} ${isActive ? 'highlight-focus' : ''}`}
// 设置唯一标识,用于滚动定位和DOM查询
data-review-id={point.id}
// 设置高亮区域的样式
style={{
position: 'absolute', // 绝对定位,相对于页面容器
zIndex: isActive ? 20 : 10, // 激活状态时提高层级,确保在最上层显示
boxShadow: isActive ? '0 0 0 2px yellow, 0 0 10px rgba(0,0,0,0.3)' : '', // 激活时添加特殊阴影效果
// 根据评查点的位置信息计算高亮区域位置,实际项目中需根据真实位置坐标计算
top: `${point.position?.index ? point.position.index * 20 : 20}px`,
left: '50px',
width: '300px',
height: '30px',
}}
/>
);
})} */}
</div>
</div>
);
}
// 返回所有页面组件数组
return pages;
};
// 渲染文档内容
const renderDocumentContent = () => {
const real_path = fileContent.path || fileContent.template_contract_path || '';
// 如果路径无效,显示错误信息
if (!real_path) {
if(!fileContent.template_contract_path){
return (
<div className="text-red-500 p-4">
<p></p>
</div>
);
}
return (
<div className="text-red-500 p-4">
<p></p>
</div>
);
}
// console.log('real_path',real_path);
// 获取文件扩展名
const fileExtension = real_path.split('.').pop()?.toLowerCase();
// PDF内容渲染
const renderPdfContent = () => (
<div
style={{
...styles.pdfContainer,
// 当缩放大于100%时设置最小宽度,确保出现横向滚动条
minWidth: zoomLevel > 100 ? `${zoomLevel}%` : '100%',
overflow: 'visible'
}}
>
<Document
file={DOCUMENT_URL+real_path}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={(error) => {
console.error("PDF加载错误:", error);
setLoadError("PDF文档加载失败:" + (error.message || "未知错误"));
}}
className="w-full"
error={<div className="text-red-500">PDF文档加载失败</div>}
noData={<div></div>}
loading={<div className="text-center py-10">PDF加载中...</div>}
>
{renderAllPages()}
</Document>
</div>
);
// 结构化数据渲染
const renderStructuredData = () => (
<div className="structured-view p-4 text-left mt-4 border-t border-gray-200">
<div className="text-xs font-medium mb-2"></div>
{fileContent.ocrResult ? (
<div className="overflow-auto max-h-[300px]">
<pre className="text-xs whitespace-pre-wrap bg-gray-50 p-3 rounded">
{JSON.stringify(fileContent.ocrResult, null, 2)}
</pre>
</div>
) : (
<div className="text-gray-500 p-4 text-center">
<p></p>
</div>
)}
</div>
);
// 根据文件类型选择不同的渲染方式
if (fileExtension === 'pdf') {
// 结构化视图模式:显示PDF和结构化数据
if (isStructuredView) {
return (
<div>
{renderPdfContent()}
{renderStructuredData()}
</div>
);
}
// 普通模式:仅显示PDF
return renderPdfContent();
} else {
// 非PDF文件显示不支持消息
return (
<div className="text-gray-500 p-4">
<p>{fileExtension}</p>
</div>
);
}
};
return (
<div className="file-preview">
<div className="file-preview-header px-2 text-xs sm:text-xs md:text-sm max-w-full flex items-center justify-between min-w-0">
<div className="flex items-center min-w-0 flex-shrink-0">
<i className={`${isStructuredView ? 'ri-file-list-line' : 'ri-file-text-line'} text-primary mr-2 flex-shrink-0`}></i>
<span className="font-medium text-primary truncate max-w-[120px]" title={isStructuredView ? '模板预览' : '文件预览'}>
{isStructuredView ? '模板预览' : '文件预览'}
</span>
</div>
<div className="file-preview-actions flex items-center ml-2 min-w-0 flex-1 justify-end overflow-hidden">
<button
className="ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5 flex-shrink-0"
onClick={handleScrollToTop}
title="返回顶部"
>
<i className="ri-arrow-up-double-line"></i>
<span className="ml-1 hidden sm:hidden md:hidden lg:hidden xl:inline truncate max-w-[60px]"></span>
</button>
<button
className="ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5 flex-shrink-0"
onClick={handleZoomIn}
title="放大"
>
<i className="ri-zoom-in-line"></i>
</button>
<button
className="ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5 flex-shrink-0"
onClick={handleZoomOut}
title="缩小"
>
<i className="ri-zoom-out-line"></i>
</button>
{/* 页码跳转控件 */}
<div className="inline-flex items-center ml-2 flex-shrink-0">
<input
type="text"
className="ant-input ant-input-sm py-0 px-1 text-xs max-h-6 leading-5 w-[2.5rem] text-center
focus:outline-none focus:ring-1 focus:ring-green-900"
placeholder="页码"
value={pageInputValue}
onChange={handlePageInputChange}
onKeyDown={handlePageInputKeyDown}
/>
<button
className="ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5 ml-1"
onClick={handlePageJump}
disabled={!numPages}
title="跳转到页面"
>
<i className="ri-arrow-right-line"></i>
</button>
{numPages && (
<span className="ml-1 text-xs text-gray-500 hidden sm:hidden md:hidden lg:hidden xl:inline whitespace-nowrap">
/ {numPages}
</span>
)}
</div>
<span className="ml-2 text-xs text-gray-500 hidden sm:hidden md:hidden lg:hidden xl:inline whitespace-nowrap flex-shrink-0">
{zoomLevel}%
</span>
<button
className={`ant-btn ant-btn-sm ant-btn-default py-0 px-1 text-xs max-h-6 leading-5 ml-2 flex-shrink-0 ${dragMode ? 'active bg-green-300' : ''}`}
title="切换拖拽模式"
aria-pressed={dragMode}
onClick={toggleDragMode}
>
<i className="ri-drag-move-line"></i>
<span className="ml-1 hidden sm:hidden md:hidden lg:hidden xl:inline truncate max-w-[80px]">
{dragMode ? '(已激活)' : ''}
</span>
</button>
</div>
</div>
<div
className="file-preview-content"
ref={contentRef}
style={{
maxHeight: 'calc(100vh - 80px)',
overflowY: 'auto',
overflowX: 'auto',
cursor: dragCursor,
userSelect: dragMode ? 'none' : 'auto', // 拖拽模式下防止文本被选中
}}
>
<button
className="pdf-interactive-container"
aria-label="PDF文档查看区域"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onKeyDown={(e) => {
// 添加键盘导航支持
const scrollAmount = 50;
if (e.key === 'ArrowUp') {
if (contentRef.current) contentRef.current.scrollTop -= scrollAmount;
e.preventDefault();
} else if (e.key === 'ArrowDown') {
if (contentRef.current) contentRef.current.scrollTop += scrollAmount;
e.preventDefault();
} else if (e.key === 'ArrowLeft') {
if (contentRef.current) contentRef.current.scrollLeft -= scrollAmount;
e.preventDefault();
} else if (e.key === 'ArrowRight') {
if (contentRef.current) contentRef.current.scrollLeft += scrollAmount;
e.preventDefault();
}
}}
style={{
position: 'relative',
height: '100%',
width: '100%',
display: 'block',
border: 'none',
background: 'transparent',
textAlign: 'center',
padding: 0
}}
>
{loadError ? (
<div className="text-red-500 p-4">
<p>{loadError}</p>
</div>
) : (
renderDocumentContent()
)}
</button>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
/**
* 交叉评查详情组件导出文件
*/
export { FileInfo } from './FileInfo';
export { FilePreview } from './FilePreview';
export { ReviewPointsList } from './ReviewPointsList';
export type { ReviewPoint } from './ReviewPointsList';
+1 -2
View File
@@ -222,7 +222,7 @@ export default function CrossCheckingIndex() {
type="default"
size="small"
className="operation-btn secondary"
onClick={() => navigate(`/cross-checking/${task.id}/results`)}
onClick={() => navigate(`/cross-checking/result?id=1355&previousRoute=cross-checking`)}
>
<i className="ri-file-text-line"></i>
@@ -478,7 +478,6 @@ export default function CrossCheckingIndex() {
name="docType"
value={searchParams.get('docType') || ''}
options={[
{ value: "", label: "全部类型" },
{ value: CrossCheckingDocType.PENALTY, label: "行政处罚" },
{ value: CrossCheckingDocType.PERMIT, label: "行政许可" }
]}
+733
View File
@@ -0,0 +1,733 @@
/**
* 交叉评查详情页面
*
* 功能概述:
* - 显示文档交叉评查结果和详细信息
* - 支持查看文档内容及评查点高亮标记
* - 提供评查点列表,分为通过、警告和错误三种类型
* - 支持评查点处理,如一键替换、人工审核等功能
* - 支持导出评查报告和下载原文件
*
* 组件结构:
* - FileInfo: 显示文件基本信息和操作按钮
* - FilePreview: 文档预览组件,显示文档内容及高亮问题
* - ReviewPointsList: 评查点列表组件,显示所有评查结果
*
* 数据流转:
* 1. 页面加载时从API获取评查详情数据
* 2. 根据评查点ID关联文档中的高亮区域
* 3. 点击评查点时在文档中定位对应位置
* 4. 处理评查点时更新状态并反馈到UI
*
* @author 中国烟草AI合同及卷宗审核系统开发团队
*/
import { type MetaFunction, type LoaderFunctionArgs } from "@remix-run/node";
import { useState, useEffect } from "react";
import { useNavigate, useLoaderData } from "@remix-run/react";
import crossCheckingStyles from "~/styles/cross-checking-result.css?url";
import { getReviewPoints, updateReviewResult, confirmReviewResults } from "~/api/evaluation_points/reviews";
import { toastService } from "~/components/ui/Toast";
// 导入交叉评查详情页面组件
import {
FileInfo,
FilePreview,
ReviewPointsList
} from "~/components/cross-checking";
// 从ReviewPointsList组件中导入ReviewPoint类型
import { type ReviewPoint } from '~/components/cross-checking';
import { messageService } from "~/components/ui/MessageModal";
import { loadingBarService } from "~/components/ui/LoadingBar";
import { Breadcrumb } from "~/components/layout/Breadcrumb";
// 定义评分提案数据接口(与reviews.ts中保持一致)
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;
}
/**
* 文件信息组件
* 显示文件名称、状态信息以及操作按钮(下载原文件、导出评查报告、确认评查结果)
*/
// 格式化文件大小
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// 定义统计数据类型
interface Statistics {
total: number;
success: number;
warning: number;
error: number;
score: number;
}
// 定义文件信息类型
interface FileInfo {
fileName: string;
contractNumber: string;
fileSize: string;
fileFormat: string;
pageCount: number;
uploadTime: string;
uploadUser: string;
auditStatus: number;
fileType: string; // 文件类型(1:合同,2:卷宗等)
}
// 定义合同信息类型
interface ContractInfo {
contractType: string;
signDate: string;
parties: {
partyA: string;
partyB: string;
};
amount: string;
period: string;
}
// 定义评查信息类型
interface ReviewInfo {
reviewTime: string;
reviewModel: string;
ruleGroup: string;
result: string;
issueCount: number;
}
// 定义文档内容类型
interface FileContent {
title: string;
contractNumber: string;
parties: {
partyA: {
name: string;
address: string;
representative: string;
phone: string;
};
partyB: {
name: string;
address: string;
representative: string;
phone: string;
};
};
sections: {
title: string;
content: string;
}[];
}
// 定义分析项类型
interface AnalysisItem {
title: string;
content: string;
description: string;
}
// 定义分析数据类型
interface AnalysisData {
riskAlerts: AnalysisItem[];
suggestions: AnalysisItem[];
summary: string;
}
// 定义评查数据类型
interface ReviewData {
fileInfo: FileInfo;
contractInfo: ContractInfo;
reviewInfo: ReviewInfo;
statistics: Statistics;
fileContent: FileContent;
reviewPoints: ReviewPoint[];
aiAnalysis: AnalysisData;
}
export const meta: MetaFunction = () => {
return [
{ title: "交叉评查详情 - 中国烟草AI合同及卷宗审核系统" },
{
name: "description",
content: "查看文档交叉评查结果,处理问题点,确认评查结果"
}
];
};
export function links() {
return [{ rel: "stylesheet", href: crossCheckingStyles }];
}
export const handle = {
hideBreadcrumb: true
};
export async function loader({ request }: LoaderFunctionArgs) {
try {
const url = new URL(request.url);
const id = url.searchParams.get('id') || undefined;
const previousRoute = url.searchParams.get('previousRoute') || '';
// console.log("id-------",id);
if (!id) {
return Response.json({ result: false, message: '文件ID不能为空' });
}
// 获取评查点数据
const reviewData = await getReviewPoints(id);
// console.log("documentData-------",JSON.stringify(documentData.data,null,2));
// console.log("reviewData-------",JSON.stringify('data' in reviewData ? reviewData.data : '',null,2));
if ('error' in reviewData && reviewData.error) {
console.error("获取评查点数据错误:", reviewData.error);
return Response.json({ result: false, message: reviewData.error });
}
// 确保reviewData有效且具有预期的属性
if ('document' in reviewData && 'data' in reviewData && 'reviewInfo' in reviewData && 'stats' in reviewData) {
// console.log("reviewData-------",JSON.stringify(reviewData.data));
return Response.json({
previousRoute: previousRoute,
document: reviewData.document,
reviewPoints: reviewData.data,
reviewInfo: reviewData.reviewInfo,
statistics: reviewData.stats,
comparison_document: reviewData.comparison_document,
scoring_proposals: reviewData.scoring_proposals || []
});
} else {
console.error("返回的评查数据格式不正确",JSON.stringify(reviewData,null,2));
return Response.json({ result: false, message: '返回的评查数据格式不正确' });
}
} catch (error) {
console.error('获取评查数据失败:', error);
return Response.json({ result: false, message: '获取评查数据失败' });
}
}
export default function CrossCheckingResult() {
const navigate = useNavigate();
const loaderData = useLoaderData<typeof loader>();
const { document, reviewPoints, statistics, reviewInfo, scoring_proposals } = loaderData;
const [isLoading, setIsLoading] = useState(false); // 已经通过loader加载了数据,不需要再显示加载状态
const [reviewData, setReviewData] = useState<ReviewData | null>(null);
const [activeReviewPointResultId, setActiveReviewPointResultId] = useState<string | null>(null);
const [targetPage, setTargetPage] = useState<number | undefined>(undefined);
// loader 数据加载出错
useEffect(()=>{
loadingBarService.hide();
if(Object.keys(loaderData).find(key => key === 'result') && !loaderData.result){
messageService.show({
title: '错误',
message: loaderData.message,
type: 'error',
confirmText: '确定',
cancelText: '',
onConfirm: () => {
navigate(-1);
}
})
}
},[loaderData, navigate]);
// 模拟获取评查数据
useEffect(() => {
if (!document) return;
// 构建文件信息对象
const fileInfo = {
fileName: document.name || "未知文件名",
path: document.path || "未知路径",
contractNumber: document.documentNumber || "未知编号",
fileSize: document.size ? formatFileSize(document.size) : "未知大小",
// 文件格式类型
fileFormat: document.fileType ? document.fileType.toUpperCase() : "未知格式",
pageCount: document.pageCount || 0,
uploadTime: document.uploadTime || "未知时间",
uploadUser: document.uploadUser || "未知用户",
auditStatus: document.auditStatus || 0,
legalBasis: document.legalBasis || {},
// 文件类型(1:合同,2:卷宗。。。)
fileType: document.type || ""
};
// 创建包含真实文档数据的评查数据对象
const reviewDataObj: ReviewData = {
// 使用真实文件信息
fileInfo: fileInfo,
// 其他字段暂时使用默认值
contractInfo: getMockReviewData().contractInfo,
reviewInfo: reviewInfo,
statistics: statistics,
fileContent: getMockReviewData().fileContent,
reviewPoints: reviewPoints,
aiAnalysis: getMockReviewData().aiAnalysis,
};
setReviewData(reviewDataObj);
setIsLoading(false);
}, [document, reviewPoints, statistics, reviewInfo]);
const handleReviewPointSelect = (reviewPointId: string, page?: number) => {
// 如果点击的是相同的评查点,但有page参数,先重置targetPage以确保useEffect能够触发
if (reviewPointId === activeReviewPointResultId && page) {
setTargetPage(undefined);
// 使用setTimeout确保状态更新后再设置新的targetPage
setTimeout(() => {
setActiveReviewPointResultId(reviewPointId);
setTargetPage(page);
}, 0);
} else {
// 正常设置activeReviewPointId和targetPage
setActiveReviewPointResultId(reviewPointId);
setTargetPage(page);
}
};
// 处理评审点状态变更
const handleReviewPointStatusChange = async (reviewPointResultId: string, editAuditStatusId: string | number, newStatus: string, message: string) => {
// 将字符串的布尔值转换为布尔类型
let boolResult = 'review';
if(newStatus !== 'review'){
boolResult = newStatus === 'true' ? 'true' : 'false';
}
try {
// 调用 API 更新评查结果
const response = await updateReviewResult(reviewPointResultId, editAuditStatusId, boolResult, message);
if (response.error) {
console.error('更新评查结果失败:', response.error);
toastService.error(`更新评查结果失败: ${response.error}`);
return;
}
// console.log('评查点状态更新成功:', {
// id: reviewPointResultId,
// result: boolResult,
// message: message
// });
// 更新本地状态
if (reviewData) {
// 找到要更新的评查点和它的原始状态
const reviewPointToUpdate = reviewData.reviewPoints.find(point => point.id === reviewPointResultId);
const oldStatus = reviewPointToUpdate?.status || '';
const wasSuccess = reviewPointToUpdate?.result === true;
const newIsSuccess = newStatus === 'true';
// 更新评查点
const updatedReviewPoints = reviewData.reviewPoints.map(point =>
point.id === reviewPointResultId ? {
...point,
result: newStatus === 'true' ? true : (newStatus === 'false' ? false : point.result),
editAuditStatus: boolResult === 'review' ? 0 : 1,
title: boolResult === 'review' ? point.title : message,
editAuditStatusMessage: boolResult === 'review' ? point.editAuditStatusMessage : message
} : point
);
// 更新统计数据
const updatedStatistics = { ...reviewData.statistics };
// 只处理结果实际变化的情况,即从通过变为不通过,或从不通过变为通过
if (newStatus !== 'review' && wasSuccess !== newIsSuccess) {
if (newIsSuccess) {
// 从不通过变为通过
updatedStatistics.success += 1;
// 减少对应的错误或警告数量
if (oldStatus === 'warning') {
updatedStatistics.warning = Math.max(0, updatedStatistics.warning - 1);
} else if (oldStatus === 'error') {
updatedStatistics.error = Math.max(0, updatedStatistics.error - 1);
}
} else {
// 从通过变为不通过
updatedStatistics.success = Math.max(0, updatedStatistics.success - 1);
// 增加对应的错误或警告数量
if (oldStatus === 'warning') {
updatedStatistics.warning += 1;
} else if (oldStatus === 'error') {
updatedStatistics.error += 1;
}
}
}
// 更新 UI 状态
setReviewData({
...reviewData,
reviewPoints: updatedReviewPoints,
statistics: updatedStatistics
});
// 显示成功消息
if(document && document.id && newStatus !== 'review'){
toastService.success('评查点状态已更新');
}
// console.log("newReviewPoints",updatedReviewPoints);
// 如果是review操作才调用API刷新
// if (document && document.id && newStatus === 'review') {
// await refreshReviewData(document.id.toString());
// }
}
} catch (error) {
console.error('更新评查结果出错:', error);
toastService.error('更新评查结果失败,请稍后重试');
}
};
const handleConfirmResults = async () => {
if (!document || !document.id) {
toastService.error('文档数据不完整,无法确认评查结果');
return;
}
try {
// 显示加载状态
setIsLoading(true);
// 调用API确认评查结果
const response = await confirmReviewResults(document.id.toString());
if (response.error) {
console.error('确认评查结果失败:', response.error);
toastService.error(`确认评查结果失败: ${response.error}`);
return;
}
// 显示成功消息
toastService.success('评查结果已确认,文档审核状态已更新');
// 导航到交叉评查列表页
navigate('/cross-checking');
} catch (error) {
console.error('确认评查结果出错:', error);
toastService.error(`确认评查结果失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
setIsLoading(false);
}
};
// 构建自定义面包屑项
const getBreadcrumbItems = () => {
const items = [
{ title: "交叉评查详情", to: `/cross-checking/result?id=${document?.id}` }
];
// 添加前置路由
if (loaderData.previousRoute) {
if (loaderData.previousRoute === 'cross-checking') {
items.unshift({ title: "交叉评查", to: "/cross-checking" });
}
}
return items;
};
return (
<div className="cross-checking-result-container">
{isLoading ? (
<div className="flex justify-center items-center p-12">
<div className="loading-spinner"></div>
<span className="ml-3">...</span>
</div>
) : reviewData && (
<>
{/* 自定义面包屑 */}
<div className="flex justify-between items-center mb-4">
<Breadcrumb
items={getBreadcrumbItems()}
className="items-center flex !mb-0"
/>
{/* 在面包屑右侧显示精简版的FileInfo */}
<div className=" ml-10 text-left flex-1 flex flex-row flex-wrap">
<span className="mr-2 text-xl font-medium">
{reviewData.fileInfo.fileName}
</span>
<div className="text-xs text-gray-500 flex items-center">
{/* 合同编号:{reviewData.fileInfo.contractNumber} */}
{ reviewData.fileInfo.fileType != "1" ? "卷宗" : "合同" }
{reviewData.fileInfo.contractNumber}
{reviewData.fileInfo.fileSize && (
<span className="text-xs text-gray-500 ml-2">
| {reviewData.fileInfo.fileSize} | {reviewData.fileInfo.fileFormat} | {reviewData.fileInfo.pageCount}&nbsp;
</span>
)}
{reviewData.fileInfo.uploadTime && (
<div className="text-xs text-gray-500">
| {reviewData.fileInfo.uploadTime}
{/* | 上传用户:{reviewData.fileInfo.uploadUser} */}
</div>
)}
</div>
</div>
{/* 完成评查按钮 */}
<button
onClick={handleConfirmResults}
className="inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-green-800 border border-transparent rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-800"
>
<i className="ri-check-double-line mr-1.5"></i>
</button>
</div>
{/* 文件信息和操作按钮 */}
{/* <FileInfo
fileInfo={{
...reviewData.fileInfo,
previousRoute: loaderData.previousRoute
}}
onConfirmResults={handleConfirmResults}
/> */}
{/* 交叉评查结果内容 */}
<div className="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:space-x-4 lg:justify-between">
{/* 左侧:文件预览 */}
<div className="w-full lg:w-[62%]">
<FilePreview
fileContent={document}
reviewPoints={reviewData.reviewPoints}
activeReviewPointResultId={activeReviewPointResultId}
targetPage={targetPage}
/>
</div>
{/* 右侧:评查结果 */}
<div className="w-full lg:w-[35%]">
<ReviewPointsList
reviewPoints={reviewData.reviewPoints}
statistics={reviewData.statistics}
activeReviewPointResultId={activeReviewPointResultId}
onReviewPointSelect={handleReviewPointSelect}
onStatusChange={handleReviewPointStatusChange}
scoringProposals={scoring_proposals as ScoringProposal[]}
/>
</div>
</div>
</>
)}
</div>
);
}
// 模拟评查数据
function getMockReviewData(): ReviewData {
return {
fileInfo: {
fileName: "烟草产品销售合同(2023版).docx",
contractNumber: "XS-2023-1025-001",
fileSize: "5.2MB",
fileFormat: "DOCX",
pageCount: 5,
uploadTime: "2023-10-25 14:30:45",
uploadUser: "张三",
auditStatus: 0,
fileType: "1"
},
contractInfo: {
contractType: "销售合同",
signDate: "2023年10月20日",
parties: {
partyA: "XX烟草公司",
partyB: "YY贸易有限公司"
},
amount: "¥ 1,580,000.00",
period: "2023年11月1日至2024年10月31日"
},
reviewInfo: {
reviewTime: "2023-10-25 14:35:12",
reviewModel: "DeepSeek",
ruleGroup: "合同标准规则组",
result: "warning",
issueCount: 9
},
statistics: {
total: 15,
success: 6,
warning: 7,
error: 2,
score: 75
},
fileContent: {
title: "烟草产品销售合同",
contractNumber: "XS-2023-1025-001",
parties: {
partyA: {
name: "XX烟草公司",
address: "XX省XX市XX区XX路XX号",
representative: "张XX",
phone: "123-4567-8901"
},
partyB: {
name: "YY贸易有限公司",
address: "XX省XX市XX区YY路YY号",
representative: "李YY",
phone: "123-4567-8902"
}
},
sections: [
{
title: "总则",
content: "1.1 本合同适用于甲乙双方之间的烟草制品买卖事宜。\n1.2 双方应本着平等互利、诚实信用的原则履行本合同。"
},
{
title: "合同标的物",
content: "2.1 产品名称:烟草制品\n2.2 规格型号:如附件所列\n2.3 数量:5000箱\n2.4 质量要求:符合国家标准GB/T XXXXX-XXXX"
},
{
title: "交货与付款",
content: "3.1 交货时间:自合同签订之日起30日内。\n3.2 乙方应在收到货物之日起5个工作日内支付合同款项,甲方应在收到乙方全部付款后开具增值税专用发票,乙方应在收到发票后支付剩余款项。\n3.3 交货地点:乙方指定的仓库。\n3.4 运输方式:陆运,运费由甲方承担。"
},
{
title: "合同文本",
content: "本合同一式两份,甲乙双方各执一份,具有同等法律效力。"
}
]
},
reviewPoints: [
{
id: "1",
pointName: "付款条款",
title: "付款条件描述不明确",
groupName: "付款条款清晰性",
// location: "交货与付款条款",
status: "error",
editAuditStatus: 0,
content: {
'anjia':{
page: 1,
value: { text: "乙方应在收到货物之日起5个工作日内支付合同款项,甲方应在收到乙方全部付款后开具增值税专用发票,乙方应在收到发票后支付剩余款项。" }
},
'yijia':{
page: 1,
value: { text: "乙方应在收到货物之日起5个工作日内支付合同款项,甲方应在收到乙方全部付款后开具增值税专用发票,乙方应在收到发票后支付剩余款项。" }
}
},
suggestion: "乙方应在收到货物验收合格之日起5个工作日内支付合同总额的70%,甲方收到该部分款项后3个工作日内向乙方开具等额增值税专用发票;乙方应在收到发票之日起5个工作日内支付剩余30%款项。",
position: { section: "交货与付款", index: 2 },
result: false
},
{
id: "2",
pointName: "违约责任",
title: "违约责任条款缺失",
groupName: "合同权利义务对等性",
status: "warning",
editAuditStatus: 0,
content: {
'clause': {
page: 1,
value: { text: "如合同发生纠纷,双方应协商解决。" }
}
},
suggestion: "如合同发生纠纷,双方应友好协商解决;协商不成的,任何一方均有权向甲方所在地人民法院提起诉讼。任何一方未能履行本合同约定义务,应向守约方支付合同总金额的10%作为违约金;给对方造成损失的,还应赔偿由此产生的全部损失。",
position: { section: "争议解决", index: 0 },
result: false
},
{
id: "3",
pointName: "签章审核",
title: "签章不完整",
groupName: "合同签署规范性",
status: "warning",
editAuditStatus: 0,
content: {
'signature': {
page: 5,
value: { text: "乙方(盖章):YY贸易有限公司\n代表人签字:李YY\n日期:2023年10月20日" }
}
},
suggestion: "需要联系甲方补充公章",
needsHumanReview: true,
humanReviewNote: "需要联系甲方补充公章",
position: { section: "签章", index: 0 },
result: false
},
{
id: "9",
pointName: "交货方式",
title: "交货方式描述模糊",
groupName: "履行条款明确性",
status: "success",
editAuditStatus: 0,
content: {
'delivery': {
page: 3,
value: { text: "3.4 运输方式:陆运,运费由甲方承担。" }
}
},
suggestion: "建议补充具体的运输方式和时间",
needsHumanReview: true,
humanReviewNote: "经核实,该交货方式虽然描述不够详细,但符合行业惯例且双方已经多次合作,不会造成实际履行障碍。",
humanReviewBy: "王法务",
humanReviewTime: "2023-11-05 14:30:22",
position: { section: "交货与付款", index: 4 },
result: true
},
{
id: "10",
pointName: "法律适用",
title: "法律适用条款缺失",
groupName: "争议解决条款完整性",
status: "error",
editAuditStatus: 0,
content: {
'missing': {
page: 0,
value: { text: "" }
}
},
suggestion: "第十三条 法律适用\n本合同的订立、效力、解释、履行及争议的解决均适用中华人民共和国法律。因本合同引起的或与本合同有关的任何争议,双方应友好协商解决。协商不成的,提交甲方所在地人民法院诉讼解决。",
position: { section: "缺失", index: 0 },
result: false
}
],
aiAnalysis: {
riskAlerts: [
{
title: "风险提示",
content: "本合同缺少违约责任条款,可能导致权责不明。",
description: "根据《中华人民共和国民法典》第五百七十七条规定,建议增加违约责任条款,明确双方违约责任及赔偿方式。"
},
{
title: "完整性检查",
content: "本合同缺少法律适用条款。",
description: "根据行业惯例,销售合同应明确约定适用法律和纠纷解决方式,以避免后续争议解决时的不确定性。"
}
],
suggestions: [
{
title: "优化建议",
content: "建议完善付款条件描述。",
description: "目前合同中关于付款条件的描述存在歧义,可能导致付款时间和条件不明确。建议按系统修改建议优化。"
}
],
summary: "本合同基本结构完整,主体内容清晰,但存在多处条款描述不完善的问题,主要体现在支付条件、违约责任、不可抗力、保密条款、合同终止条件等方面。这些问题虽不影响合同的基本合规性,但可能在合同履行过程中引发争议和纠纷。同时,合同签章不完整,也影响了合同的法律效力。建议对上述问题进行修改完善后再行签署。"
}
};
}
+338
View File
@@ -0,0 +1,338 @@
:root {
--primary-color: #00684a;
--primary-hover: #005a40;
--primary-light: rgba(0, 104, 74, 0.1);
--success-color: #52c41a;
--warning-color: #faad14;
--error-color: #ff4d4f;
--text-color: rgba(0, 0, 0, 0.85);
--text-secondary: rgba(0, 0, 0, 0.45);
--border-color: #f0f0f0;
--bg-gray: #f5f5f5;
}
.cross-checking-result-container {
width: 100%;
}
.cross-checking-result-header {
margin-bottom: 16px;
}
/* 文件信息和操作按钮区域 */
.file-info-header {
margin-bottom: 16px;
}
/* 文件预览区域 */
.file-preview {
background-color: white;
border: 1px solid var(--border-color);
border-radius: 6px;
height: calc(100vh - 80px);
min-height: 500px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.file-preview-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--primary-light);
height: 36px;
}
.file-preview-content {
flex: 1;
overflow-y: auto;
position: relative;
max-width: 100%;
overflow-x: hidden;
}
/* PDF文档样式约束 */
.react-pdf__Document {
display: flex;
flex-direction: column;
align-items: center;
max-width: 100%;
}
.react-pdf__Page {
max-width: 100%;
box-sizing: border-box;
}
.react-pdf__Page__canvas {
max-width: 100%;
height: auto !important;
}
.file-preview-actions {
display: flex;
gap: 8px;
}
/* 文档内容样式 */
.word-document {
padding: 40px;
background-color: white;
font-family: 'SimSun', serif;
line-height: 1.8;
}
.word-document h1 {
font-size: 18px;
text-align: center;
font-weight: bold;
margin-bottom: 20px;
}
.word-document h2 {
font-size: 16px;
font-weight: bold;
margin-top: 16px;
margin-bottom: 8px;
}
.word-document p {
text-indent: 2em;
margin-bottom: 10px;
}
/* 高亮区域 */
.highlight-area {
display: inline-block;
position: relative;
padding: 2px;
margin: -2px;
cursor: pointer;
transition: all 0.2s;
}
.highlight-area:hover {
box-shadow: 0 0 0 3px rgba(250, 173, 20, 0.6);
}
.highlight-area.warning {
background-color: rgba(250, 173, 20, 0.2);
border: 2px solid var(--warning-color);
}
.highlight-area.error {
background-color: rgba(255, 77, 79, 0.2);
border: 2px solid var(--error-color);
}
.highlight-area.success {
background-color: rgba(82, 196, 26, 0.2);
border: 2px solid var(--success-color);
}
/* 评查点列表区域 */
.review-points-panel {
background-color: white;
border: 1px solid var(--border-color);
border-radius: 6px;
height: calc(100vh - 80px);
min-height: 500px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.review-panel-header {
border-bottom: 1px solid var(--border-color);
background-color: var(--primary-light);
height: 36px;
display: flex;
align-items: center;
padding: 0 16px;
}
.review-statistics {
padding: 16px;
border-bottom: 1px solid var(--border-color);
background-color: white;
}
.review-points-list {
flex: 1;
overflow-y: auto;
padding: 2px;
}
.review-point-item {
padding: 5px 10px 10px 10px;
cursor: pointer;
width: 100%;
}
.review-point-item:hover {
box-shadow: 1px 4px 10px rgba(0, 0, 0, 0.08);
}
.review-point-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
flex-wrap: wrap;
}
.text-error {
color: #f5222d;
}
.text-warning {
color: #faad14;
}
.review-point-title {
text-align: left;
font-weight: 500;
font-size: 13px;
line-height: 1.3;
margin-bottom: 3px;
}
.review-point-location {
display: flex;
align-items: center;
font-size: 12px;
color: var(--text-secondary);
background-color: #f5f5f5;
border-radius: 4px;
padding: 2px 8px;
width: fit-content;
align-self: flex-start;
}
/* 状态标签 */
.status-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
.status-waiting {
background-color: #f9f0ff;
color: #722ed1;
}
.status-processing {
background-color: #e6f7ff;
color: #1890ff;
}
.status-success {
background-color: #e7ffcd;
color: #52c41a;
}
.status-error {
background-color: #fff1f0;
color: #f5222d;
}
.status-warning {
background-color: #fff2aec7;
color: #faad14;
}
/* 替换动作按钮 */
.replace-action {
background-color: #1890ff;
color: white;
border: none;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
margin-top: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
}
.replace-action i {
margin-right: 4px;
}
.replace-action:hover {
background-color: #40a9ff;
}
/* 人工审核标记 */
.human-review-badge {
display: inline-flex;
align-items: center;
background-color: #e6f7ff;
color: #1890ff;
padding: 1px 4px;
border-radius: 4px;
font-size: 11px;
margin-left: 4px;
line-height: 1.2;
}
.human-review-note {
background-color: #e6f7ff;
border-left: 3px solid #1890ff;
padding: 6px 10px;
margin-top: 6px;
font-size: 12px;
color: #333;
text-align: left;
}
/* 文件详情页面 */
.info-section {
margin-bottom: 24px;
border-radius: 6px;
overflow: hidden;
}
.info-header {
padding: 12px 16px;
display: flex;
align-items: center;
}
.info-content {
padding: 16px;
background-color: white;
}
.info-row {
display: flex;
margin-bottom: 12px;
}
.info-label {
width: 120px;
color: var(--text-secondary);
}
.info-value {
flex: 1;
}
.text-success {
color: #52c41a;
}
@media (max-width: 768px) {
.container {
width: 100%;
}
}