Files
leaudit-platform-frontend/app/routes/reviewsTest.tsx
T
2026-05-06 10:01:21 +08:00

1272 lines
50 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.
/**
* 评查详情页面
*
* 功能概述:
* - 显示文档评查结果和详细信息
* - 支持查看文档内容及评查点高亮标记
* - 提供评查点列表,分为通过、警告和错误三种类型
* - 支持评查点处理,如一键替换、人工审核等功能
* - 支持导出评查报告和下载原文件
*
* 组件结构:
* - FileInfo: 显示文件基本信息和操作按钮
* - ReviewTabs: 页面选项卡,包括评查结果、AI智能分析和文件信息
* - FilePreview: 文档预览组件,显示文档内容及高亮问题
* - ReviewPointsList: 评查点列表组件,显示所有评查结果
* - AIAnalysis: AI智能分析结果,提供综合评价
* - FileDetails: 文件详情信息
*
* 数据流转:
* 1. 页面加载时从API获取评查详情数据
* 2. 根据评查点ID关联文档中的高亮区域
* 3. 点击评查点时在文档中定位对应位置
* 4. 处理评查点时更新状态并反馈到UI
*
* @author 中国烟草AI合同及卷宗审核系统开发团队
*/
import { type MetaFunction, type LoaderFunctionArgs, type ActionFunctionArgs } from "@remix-run/node";
import { useState, useEffect, useCallback, useRef } from "react";
import { useNavigate, useLoaderData, useFetcher, useRevalidator } from "@remix-run/react";
import reviewsStyles from "~/styles/reviews.css?url";
import { getReviewPoints_fromApi, getUnifiedEvaluationResults, updateReviewResult, confirmReviewResults } from "~/api/evaluation_points/reviews";
import { toastService } from "~/components/ui/Toast";
import { Modal } from "~/components/ui/Modal";
import { UploadArea, type UploadAreaRef } from "~/components/ui/UploadArea";
import { Button } from "~/components/ui/Button";
import { uploadContractTemplate } from "~/api/files/files-upload";
import { Comparison } from "~/components/reviews/Comparison";
import axios from "axios";
// 导入新的三栏组件
import { RulesDirectory } from "~/components/reviews/leftColumn/RulesDirectory";
import { DetailPanel } from "~/components/reviews/rightColumn/DetailPanel";
import { PdfPreviewTest } from "~/components/reviews/previewComponents/PdfPreviewTest";
import { DocxPreviewTest } from "~/components/reviews/previewComponents/DocxPreviewTest";
import { ComparePreview } from "~/components/reviews/previewComponents/ComparePreview";
import { type ReviewPoint } from '~/components/reviews';
import { messageService } from "~/components/ui/MessageModal";
import { loadingBarService } from "~/components/ui/LoadingBar";
/**
* 文件信息组件
* 显示文件名称、状态信息以及操作按钮(下载原文件、导出评查报告、确认评查结果)
*/
// 格式化文件大小
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;
notApplicable: 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;
}
function buildReviewData(document: any, reviewPoints: ReviewPoint[], statistics: Statistics, reviewInfo: ReviewInfo): ReviewData | null {
if (!document) {
return null;
}
const mockData = getMockReviewData();
const typeValue = document.type ?? document.type_id;
return {
fileInfo: {
fileName: document.name || "未知文件名",
path: document.path || "未知路径",
contractNumber: document.documentNumber || document.document_number || "未知编号",
fileSize: document.size ? formatFileSize(document.size) : document.file_size ? formatFileSize(document.file_size) : "未知大小",
fileFormat: document.fileType ? document.fileType.toUpperCase() : "未知格式",
pageCount: document.pageCount || document.page_count || 0,
uploadTime: document.uploadTime || document.created_at || "未知时间",
uploadUser: document.uploadUser || "未知用户",
auditStatus: document.auditStatus || 0,
legalBasis: document.legalBasis || {},
fileType: typeValue !== undefined && typeValue !== null ? String(typeValue) : ""
},
contractInfo: mockData.contractInfo,
reviewInfo,
statistics,
fileContent: mockData.fileContent,
reviewPoints,
aiAnalysis: mockData.aiAnalysis,
};
}
export const meta: MetaFunction = () => {
return [
{ title: "评查详情 - 中国烟草AI合同及卷宗审核系统" },
{
name: "description",
content: "查看文档评查结果,处理问题点,确认评查结果"
}
];
};
export function links() {
return [{ rel: "stylesheet", href: reviewsStyles }];
}
export const handle = {
hideBreadcrumb: true,
collapseSidebar: true,
noPadding: 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("[Reviews Loader] 开始加载,id:", id, "previousRoute:", previousRoute);
if (!id) {
console.error("[Reviews Loader] 文件ID不能为空");
return Response.json({ result: false, message: '文件ID不能为空' });
}
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { userInfo, frontendJWT } = await getUserSession(request);
// 🆕 使用新的统一API获取评查点数据
// 先尝试新的统一评查接口
const unifiedData = await getUnifiedEvaluationResults(id, request);
// 如果统一接口返回错误或 flow_type 为 legacy,直接走新后端聚合接口
if ('error' in unifiedData || !unifiedData.flow_type) {
console.log("[Reviews Loader] 统一接口不可用,直接尝试 review-points 聚合接口...");
const reviewData = await getReviewPoints_fromApi(id, request);
if ('error' in reviewData && reviewData.error) {
console.error("[Reviews Loader] 获取评查点数据错误:", reviewData.error);
return Response.json({ result: false, message: reviewData.error });
}
if ('document' in reviewData && 'data' in reviewData && 'reviewInfo' in reviewData && 'stats' in reviewData) {
return Response.json({
previousRoute: previousRoute,
document: reviewData.document,
reviewPoints: reviewData.data,
reviewInfo: reviewData.reviewInfo,
statistics: reviewData.stats,
comparison_document: reviewData.comparison_document,
userInfo,
frontendJWT,
flowType: 'legacy',
scoredResults: null,
scoredSummary: null
});
}
}
// 统一接口成功返回,判断流程类型
if (unifiedData.flow_type === 'graphrag') {
// 先获取文档基本信息(统一接口不返回文档内容)
const reviewData = await getReviewPoints_fromApi(id, request);
// 合并已评查的 reviewPoints + 未涉及的评查点
const existingPoints = ('data' in reviewData && !('error' in reviewData)) ? reviewData.data : [];
const notApplicablePoints = (unifiedData.results || [])
.filter((r: any) => r.result_type === 'not_applicable')
.map((r: any) => ({
id: `na-${r.evaluation_point_id}`,
documentId: id,
pointId: r.evaluation_point_id,
editAuditStatusId: '',
editAuditStatus: '',
editAuditStatusMessage: '',
title: '该评查点未涉及',
pointName: r.name || '',
pointCode: r.code || '',
groupName: '',
status: 'notApplicable',
content: {},
contentPage: {},
suggestion: r.ai_suggestion || '该评查点未涉及',
result: null,
score: r.score || 0,
finalScore: null,
machineScore: 0,
postAction: '',
}));
const allReviewPoints = [...existingPoints, ...notApplicablePoints];
return Response.json({
previousRoute: previousRoute,
document: ('document' in reviewData && !('error' in reviewData)) ? reviewData.document : null,
reviewPoints: allReviewPoints,
reviewInfo: { reviewTime: unifiedData.evaluated_at, reviewModel: 'GraphRAG', ruleGroup: '', result: '', issueCount: unifiedData.summary?.total_points || 0 },
statistics: {
total: unifiedData.summary?.total_points || 0,
success: unifiedData.summary?.passed_count || 0,
warning: unifiedData.summary?.failed_count || 0,
error: 0,
notApplicable: unifiedData.summary?.not_applicable_count || 0,
score: unifiedData.summary?.total_score || 0
},
comparison_document: ('comparison_document' in reviewData && !('error' in reviewData)) ? reviewData.comparison_document : null,
userInfo,
frontendJWT,
flowType: 'graphrag',
scoredResults: unifiedData.results,
scoredSummary: unifiedData.summary
});
} else {
// legacy 流程但统一接口可用,也统一走 review-points 聚合接口
const reviewData = await getReviewPoints_fromApi(id, request);
if ('error' in reviewData && reviewData.error) {
return Response.json({ result: false, message: reviewData.error });
}
return Response.json({
previousRoute: previousRoute,
document: reviewData.document,
reviewPoints: reviewData.data,
reviewInfo: reviewData.reviewInfo,
statistics: reviewData.stats,
comparison_document: reviewData.comparison_document,
userInfo,
frontendJWT,
flowType: 'legacy',
scoredResults: null,
scoredSummary: null
});
}
} catch (error) {
console.error('[Reviews Loader] 获取评查数据失败:', error);
console.error('[Reviews Loader] 错误堆栈:', error instanceof Error ? error.stack : '无堆栈信息');
return Response.json({ result: false, message: `获取评查数据失败: ${error instanceof Error ? error.message : '未知错误'}` });
}
}
// 添加 action 函数处理需要用户认证的操作
export async function action({ request }: ActionFunctionArgs) {
try {
const formData = await request.formData();
const intent = formData.get("intent") as string;
// console.log('Action接收到请求, intent:', intent);
if (intent === "updateReviewResult") {
const reviewPointResultId = formData.get("reviewPointResultId") as string;
const editAuditStatusId = formData.get("editAuditStatusId") as string;
const result = formData.get("result") as string;
const message = formData.get("message") as string;
// console.log('更新评查结果参数:', { reviewPointResultId, editAuditStatusId, result, message });
try {
const response = await updateReviewResult(reviewPointResultId, editAuditStatusId, result, message, request);
if (response.error) {
console.error('updateReviewResult返回错误:', response.error);
return Response.json({ success: false, error: response.error }, { status: response.status || 500 });
}
return Response.json({ success: true, data: response.data, intent: "updateReviewResult" });
} catch (updateError) {
console.error('调用updateReviewResult时发生异常:', updateError);
return Response.json({
success: false,
error: updateError instanceof Error ? updateError.message : '更新评查结果时发生未知错误'
}, { status: 500 });
}
}
if (intent === "confirmReviewResults") {
const documentId = formData.get("documentId") as string;
// console.log('确认评查结果参数:', { documentId });
try {
const response = await confirmReviewResults(documentId, request);
if (response.error) {
console.error('confirmReviewResults返回错误:', response.error);
return Response.json({ success: false, error: response.error, intent: "confirmReviewResults" }, { status: response.status || 500 });
}
return Response.json({ success: true, data: response.data, intent: "confirmReviewResults" });
} catch (confirmError) {
console.error('调用confirmReviewResults时发生异常:', confirmError);
return Response.json({
success: false,
error: confirmError instanceof Error ? confirmError.message : '确认评查结果时发生未知错误',
intent: "confirmReviewResults"
}, { status: 500 });
}
}
console.error('收到未知的操作类型:', intent);
return Response.json({ success: false, error: "未知的操作类型" }, { status: 400 });
} catch (error) {
console.error('Action处理失败:', error);
return Response.json({
success: false,
error: error instanceof Error ? error.message : '操作失败'
}, { status: 500 });
}
}
export default function ReviewDetails() {
const navigate = useNavigate();
const loaderData = useLoaderData<typeof loader>();
const normalizedLoaderData =
loaderData &&
typeof loaderData === 'object' &&
'reviewPoints' in loaderData &&
loaderData.reviewPoints &&
typeof loaderData.reviewPoints === 'object' &&
'data' in loaderData.reviewPoints &&
'document' in loaderData.reviewPoints
? {
...loaderData,
document: (loaderData.reviewPoints as any).document,
reviewPoints: (loaderData.reviewPoints as any).data,
reviewInfo: (loaderData.reviewPoints as any).reviewInfo,
statistics: (loaderData.reviewPoints as any).stats,
comparison_document: (loaderData.reviewPoints as any).comparison_document,
scoring_proposals: (loaderData.reviewPoints as any).scoring_proposals ?? [],
}
: loaderData;
const fetcher = useFetcher();
const { document, reviewPoints, statistics, reviewInfo, comparison_document, frontendJWT } = normalizedLoaderData as any;
const fallbackReviewData = buildReviewData(document, reviewPoints, statistics, reviewInfo);
const [isLoading, setIsLoading] = useState(false); // 已经通过loader加载了数据,不需要再显示加载状态
const [rightActiveTab, setRightActiveTab] = useState<'result' | 'fields' | 'info'>('result');
const [reviewData, setReviewData] = useState<ReviewData | null>(fallbackReviewData);
const [activeReviewPointResultId, setActiveReviewPointResultId] = useState<string | null>(null);
const [targetPage, setTargetPage] = useState<number | undefined>(undefined);
const [charPositions, setCharPositions] = useState<Array<{ box: number[][], char: string, score: number }> | undefined>(undefined);
const [highlightValue, setHighlightValue] = useState<string | undefined>(undefined);
const [pendingUpdate, setPendingUpdate] = useState<{
reviewPointResultId: string;
newStatus: string;
message: string;
} | null>(null);
const [showCompareOverlay, setShowCompareOverlay] = useState(false);
// 一键替换(DOCX Collabora 使用)
const [aiSuggestionReplace, setAiSuggestionReplace] = useState<{
searchText: string;
replaceText: string;
pageNumber: number;
silentReplace?: boolean;
} | undefined>(undefined);
// 模板上传相关状态
const [isReuploadModalOpen, setIsReuploadModalOpen] = useState(false);
const [selectedTemplateFiles, setSelectedTemplateFiles] = useState<File[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [showComparison, setShowComparison] = useState(false);
const uploadAreaRef = useRef<UploadAreaRef>(null);
const revalidator = useRevalidator();
// 结构比对按钮显示条件:fileInfo.type 包含 '1'
const showComparisonButton = (document as any)?.type?.toString().includes('1');
// 🐛 调试:打印 loader 返回的完整数据到浏览器控制台
// useEffect(() => {
// if (typeof window !== 'undefined') {
// console.group('📦 [Reviews] Loader 数据');
// console.log('完整数据:', loaderData);
// console.log('文档信息:', document);
// console.log('评查点数量:', reviewPoints?.length);
// console.log('评查点数量:', reviewPoints);
// console.log('统计信息:', statistics);
// console.log('评查信息:', reviewInfo);
// console.log('比对文档:', comparison_document);
// console.log('用户信息:', loaderData.userInfo);
// console.log('JWT Token (前20位):', frontendJWT?.substring(0, 20) + '...');
// console.groupEnd();
// }
// }, [loaderData, document, reviewPoints, statistics, reviewInfo, comparison_document, frontendJWT]);
// loader 数据加载出错
useEffect(()=>{
loadingBarService.hide();
// console.log('[Reviews Component] useEffect检查loaderData:', {
// hasResultKey: Object.keys(loaderData).find(key => key === 'result'),
// resultValue: loaderData.result,
// willNavigateBack: Object.keys(loaderData).find(key => key === 'result') && !loaderData.result
// });
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]);
// 当文档 ID 变化时,清空高亮相关的状态
useEffect(() => {
if (document?.id) {
setActiveReviewPointResultId(null);
setTargetPage(undefined);
setCharPositions(undefined);
setHighlightValue(undefined);
}
}, [document?.id]);
// 使用 loader 数据同步本地评查页状态,避免首屏空白。
useEffect(() => {
setReviewData(buildReviewData(document, reviewPoints, statistics, reviewInfo));
setIsLoading(false);
}, [document, reviewPoints, statistics, reviewInfo]);
const effectiveReviewData = reviewData ?? fallbackReviewData;
const handleTabChange = (tabKey: 'result' | 'fields' | 'info') => {
setRightActiveTab(tabKey);
};
// 从左栏选择评查点
const handleRuleSelect = (id: string) => {
setActiveReviewPointResultId(id);
setRightActiveTab('result');
// 查找评查点并尝试跳转到其页面
const point = effectiveReviewData?.reviewPoints.find(p => p.id === id);
if (point) {
console.log('跳转到评查点页面:', point);
const page = getFirstPageFromPoint(point);
if (page) setTargetPage(page);
else setTargetPage(undefined);
setCharPositions(undefined);
setHighlightValue(undefined);
}
};
// 从评查点中提取第一个有效页码
const getFirstPageFromPoint = (point: ReviewPoint): number | undefined => {
if (point.content) {
for (const data of Object.values(point.content)) {
const page = (data as any)?.page;
if (page && Number(page) > 0) return Number(page);
}
}
if (point.contentPage) {
for (const page of Object.values(point.contentPage)) {
if (page && Number(page) > 0) return Number(page);
}
}
return undefined;
};
// 下载文件
const handleDownloadFile = async () => {
try {
const downloadUrl = `/api/pdf-proxy?path=${encodeURIComponent(document?.path || '')}`;
const response = await axios.get(downloadUrl, { responseType: 'blob' });
const blobUrl = URL.createObjectURL(response.data);
const a = window.document.createElement('a');
a.style.display = 'none';
a.href = blobUrl;
a.download = decodeURIComponent(document?.path?.split('/').pop() || 'document');
window.document.body.appendChild(a);
a.click();
setTimeout(() => {
window.document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
}, 100);
} catch (error) {
console.error('下载文件失败:', error);
toastService.error(`下载文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
};
const handleReviewPointSelect = (reviewPointId: string, page?: number, charPos?: Array<{ box: number[][], char: string, score: number }>, value?: string) => {
// 如果点击的是相同的评查点,但有page参数,先重置targetPage以确保useEffect能够触发
if (reviewPointId === activeReviewPointResultId && page) {
setTargetPage(undefined);
setCharPositions(undefined);
setHighlightValue(undefined);
// 使用setTimeout确保状态更新后再设置新的targetPage、charPositions和highlightValue
setTimeout(() => {
setActiveReviewPointResultId(reviewPointId);
setTargetPage(page);
setCharPositions(charPos);
setHighlightValue(value);
}, 0);
} else {
// 正常设置activeReviewPointId、targetPage、charPositions和highlightValue
setActiveReviewPointResultId(reviewPointId);
setTargetPage(page);
setCharPositions(charPos);
setHighlightValue(value);
}
};
// 处理AI建议替换(保留空实现,右栏详情卡片中DOCX替换需要)
const handleAiSuggestionReplace = (_searchText: string, _replaceText: string, _pageNumber: number) => {
// PDF 文件不支持替换,暂不实现
};
// 刷新评审数据
// async function refreshReviewData(documentId: string) {
// // 设置加载状态
// setIsLoading(true);
// try {
// // 获取最新的评审数据
// const response = await getReviewPoints(documentId);
// if ('error' in response && response.error) {
// console.error('刷新评审数据失败:', response.error);
// toastService.error(`刷新评审数据失败: ${response.error}`);
// return;
// }
// // 确保response有效且具有预期的属性
// if ('data' in response && 'stats' in response && 'reviewInfo' in response) {
// const reviewPointsData = response.data || [];
// const statisticsData = response.stats || { total: 0, success: 0, warning: 0, error: 0, score: 0 };
// const reviewInfoData = response.reviewInfo || {
// reviewTime: '',
// reviewModel: '',
// ruleGroup: '',
// result: '',
// issueCount: 0
// };
// // 更新评审数据和统计信息
// setReviewData(prevData => {
// if (!prevData) {
// // 如果prevData为null,创建一个新的ReviewData对象
// return {
// fileInfo: {
// fileName: document?.name || "",
// 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
// },
// contractInfo: getMockReviewData().contractInfo,
// reviewInfo: reviewInfoData as ReviewInfo,
// statistics: statisticsData as Statistics,
// fileContent: getMockReviewData().fileContent,
// reviewPoints: reviewPointsData as unknown as ReviewPoint[],
// aiAnalysis: getMockReviewData().aiAnalysis
// };
// }
// // 处理prevData非null的情况
// return {
// ...prevData,
// reviewPoints: reviewPointsData as unknown as ReviewPoint[],
// statistics: statisticsData as Statistics,
// reviewInfo: reviewInfoData as ReviewInfo
// };
// });
// toastService.success('评审数据已更新');
// } else {
// console.error('返回的数据格式不正确');
// toastService.error('刷新评审数据失败: 返回的数据格式不正确');
// }
// } catch (error) {
// console.error('刷新评审数据失败:', error);
// toastService.error(`刷新评审数据失败: ${error instanceof Error ? error.message : '未知错误'}`);
// } finally {
// setIsLoading(false);
// }
// }
// 监听fetcher状态变化
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data && pendingUpdate) {
const result = fetcher.data as { success: boolean; error?: string; data?: unknown };
// console.log('Fetcher返回数据:', result);
if (result.success) {
// console.log('评查点状态更新成功');
// 使用pendingUpdate中的参数更新本地状态
if (reviewData && pendingUpdate.reviewPointResultId) {
const reviewPointToUpdate = reviewData.reviewPoints.find(point => point.id === pendingUpdate.reviewPointResultId);
const oldStatus = reviewPointToUpdate?.status || '';
const wasSuccess = reviewPointToUpdate?.result === true;
const newIsSuccess = pendingUpdate.newStatus === 'true';
// 更新评查点
const updatedReviewPoints = reviewData.reviewPoints.map(point =>
point.id === pendingUpdate.reviewPointResultId ? {
...point,
result: pendingUpdate.newStatus === 'true' ? true : (pendingUpdate.newStatus === 'false' ? false : point.result),
editAuditStatus: pendingUpdate.newStatus === 'review' ? 0 : 1,
title: pendingUpdate.newStatus === 'review' ? point.title : pendingUpdate.message,
editAuditStatusMessage: pendingUpdate.newStatus === 'review' ? point.editAuditStatusMessage : pendingUpdate.message
} : point
);
// 更新统计数据
const updatedStatistics = { ...reviewData.statistics };
// 只处理结果实际变化的情况
if (pendingUpdate.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 && pendingUpdate.newStatus !== 'review'){
toastService.success('评查点状态已更新');
}
// 清除pendingUpdate
setPendingUpdate(null);
} else {
console.error('更新评查结果失败:', result.error);
toastService.error(`更新评查结果失败: ${result.error || '未知错误'}`);
// 清除pendingUpdate
setPendingUpdate(null);
}
}
}, [fetcher.state, fetcher.data, pendingUpdate, document, reviewData]);
// 监听fetcher状态变化 - 处理确认评查结果
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data && !pendingUpdate) {
const result = fetcher.data as { success: boolean; error?: string; intent?: string };
// 只处理confirmReviewResults的响应
if (result.intent === 'confirmReviewResults') {
setIsLoading(false);
if (result.success) {
toastService.success('评查结果已确认,文档审核状态已更新');
// 导航到文档列表页
navigate('/documents/list');
} else {
console.error('确认评查结果失败:', result.error);
toastService.error(`确认评查结果失败: ${result.error || '未知错误'}`);
}
}
}
}, [fetcher.state, fetcher.data, pendingUpdate, navigate]);
// 处理评审点状态变更
const handleReviewPointStatusChange = async (reviewPointResultId: string, editAuditStatusId: string | number, newStatus: string, message: string) => {
// 将字符串的布尔值转换为布尔类型
let boolResult = 'review';
if(newStatus !== 'review'){
boolResult = newStatus === 'true' ? 'true' : 'false';
}
try {
// console.log('开始提交评查结果更新:', { reviewPointResultId, editAuditStatusId, boolResult, message });
// 设置待处理的更新信息
setPendingUpdate({
reviewPointResultId,
newStatus: boolResult,
message
});
// 使用 Remix 的 useFetcher 调用 action
const formData = new FormData();
formData.append("intent", "updateReviewResult");
formData.append("reviewPointResultId", reviewPointResultId);
formData.append("editAuditStatusId", editAuditStatusId.toString());
formData.append("result", boolResult);
formData.append("message", message);
fetcher.submit(formData, { method: "POST" });
// console.log('请求已提交,等待响应...');
// 注意:本地状态更新现在在useEffect中处理,当fetcher返回成功响应时触发
} catch (error) {
console.error('更新评查结果出错:', error);
toastService.error('更新评查结果失败,请稍后重试');
}
};
const handleConfirmResults = async () => {
if (!document || !document.id) {
toastService.error('文档数据不完整,无法确认评查结果');
return;
}
try {
// 显示加载状态
setIsLoading(true);
// 使用 Remix 的 useFetcher 调用 action
const formData = new FormData();
formData.append("intent", "confirmReviewResults");
formData.append("documentId", document.id.toString());
fetcher.submit(formData, { method: "POST" });
} catch (error) {
console.error('确认评查结果出错:', error);
toastService.error(`确认评查结果失败: ${error instanceof Error ? error.message : '未知错误'}`);
setIsLoading(false);
}
};
// 构建返回路径
const getReturnUrl = () => {
if (loaderData.previousRoute === 'filesUpload') return '/files/upload';
if (loaderData.previousRoute === 'rulesFiles') return '/rules-files';
return '/documents/list';
};
// 获取当前激活的评查点对象
const activeReviewPoint = effectiveReviewData?.reviewPoints.find(p => p.id === activeReviewPointResultId) || null;
// ── 模板上传相关函数 ──
const handleOpenReuploadModal = () => {
setIsReuploadModalOpen(true);
setSelectedTemplateFiles([]);
};
const handleCloseReuploadModal = () => {
setIsReuploadModalOpen(false);
setSelectedTemplateFiles([]);
if (uploadAreaRef.current) uploadAreaRef.current.resetFileInput();
};
const handleTemplateFilesSelected = (files: FileList) => {
try {
if (files.length > 0) {
const validFiles: File[] = [];
let hasInvalidFiles = false;
Array.from(files).forEach(file => {
const fileName = file.name.toLowerCase();
const isValidType = file.type === 'application/pdf' || fileName.endsWith('.pdf') ||
file.type === 'application/msword' || fileName.endsWith('.doc') || fileName.endsWith('.docx') ||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
if (isValidType) validFiles.push(file);
else hasInvalidFiles = true;
});
if (hasInvalidFiles) toastService.error('只能上传PDF或Word格式的文件');
if (validFiles.length > 0) setSelectedTemplateFiles(validFiles);
}
} catch { toastService.error('文件选择失败,请重试'); }
};
const handleConfirmUpload = async () => {
if (selectedTemplateFiles.length === 0) { toastService.error('请先选择要上传的模板文件'); return; }
if (!(document as any)?.id) { toastService.error('文档ID不能为空'); return; }
try {
setIsUploading(true);
const uploadResult = await uploadContractTemplate(
selectedTemplateFiles[0],
(document as any).id,
(comparison_document as any)?.comparisonId,
frontendJWT || undefined
);
if (uploadResult.error) throw new Error(uploadResult.error);
toastService.success('模板文件上传成功,即将返回上一页...');
await new Promise(r => setTimeout(r, 2000));
handleCloseReuploadModal();
navigate(getReturnUrl());
} catch (error) {
toastService.error(`上传失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally { setIsUploading(false); }
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// ── 结构比对全页面视图 ──
if (showComparison) {
return (
<div className="flex flex-col h-screen overflow-hidden">
<header className="shrink-0 h-11 px-4 flex items-center gap-3 border-b border-slate-200 bg-white">
<button type="button" className="flex items-center gap-1 text-slate-600 hover:text-slate-900 text-[12.5px]" onClick={() => setShowComparison(false)}>
<i className="ri-arrow-left-line" />
</button>
<span className="font-medium text-sm text-slate-800 truncate">{effectiveReviewData?.fileInfo?.fileName}</span>
</header>
<div className="flex-1 min-h-0 overflow-auto">
<Comparison comparison_document={comparison_document} />
</div>
</div>
);
}
return (
<div className="flex flex-col h-screen overflow-hidden">
{isLoading ? (
<div className="flex justify-center items-center p-12">
<div className="loading-spinner"></div>
<span className="ml-3">...</span>
</div>
) : effectiveReviewData ? (
<main className="flex-1 min-h-0 grid grid-cols-[22%,1fr,30%] p-2">
{/* 左栏:规则目录 */}
<RulesDirectory
reviewPoints={effectiveReviewData.reviewPoints}
statistics={effectiveReviewData.statistics}
activeReviewPointResultId={activeReviewPointResultId}
fileName={effectiveReviewData.fileInfo.fileName}
onRuleSelect={handleRuleSelect}
onBack={() => navigate(getReturnUrl())}
/>
{/* 中栏:PDF 预览 */}
{/* 中栏:文件预览(根据文件类型切换) */}
<section className="flex flex-col min-h-0 bg-slate-100">
{document?.path?.split('.').pop()?.toLowerCase() === 'docx' ? (
<DocxPreviewTest
filePath={document?.path || ''}
targetPage={targetPage}
charPositions={charPositions}
activeReviewPointResultId={activeReviewPointResultId}
reviewPoints={effectiveReviewData.reviewPoints}
highlightValue={highlightValue}
aiSuggestionReplace={aiSuggestionReplace}
userInfo={(normalizedLoaderData as any)?.userInfo}
/>
) : (
<PdfPreviewTest
filePath={document?.path || ''}
targetPage={targetPage}
charPositions={charPositions}
activeReviewPointResultId={activeReviewPointResultId}
reviewPoints={effectiveReviewData.reviewPoints}
/>
)}
</section>
{/* 右栏:详情面板 */}
<DetailPanel
activeTab={rightActiveTab}
onTabChange={handleTabChange}
activeReviewPoint={activeReviewPoint}
reviewPoints={effectiveReviewData.reviewPoints}
fileInfo={effectiveReviewData.fileInfo}
reviewInfo={effectiveReviewData.reviewInfo}
onReviewPointSelect={handleReviewPointSelect}
onStatusChange={handleReviewPointStatusChange}
onConfirmResults={handleConfirmResults}
onDownload={handleDownloadFile}
auditStatus={document?.auditStatus}
fileFormat={effectiveReviewData.fileInfo.fileFormat}
onUploadTemplate={handleOpenReuploadModal}
onComparison={() => setShowComparison(true)}
showComparisonButton={showComparisonButton}
/>
</main>
) : null}
{/* 结构比对 overlay */}
{showCompareOverlay && comparison_document && (
<div className="fixed inset-0 z-50 bg-black/30 flex items-center justify-center">
<div className="bg-white rounded-lg w-[95vw] h-[90vh] flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-slate-200">
<span className="text-sm font-medium"></span>
<button
type="button"
className="w-8 h-8 grid place-items-center rounded hover:bg-slate-100 text-slate-500"
onClick={() => setShowCompareOverlay(false)}
>
<i className="ri-close-line text-lg" />
</button>
</div>
<div className="flex-1 min-h-0">
<ComparePreview
doc1Path={document?.path || ''}
doc2Path={comparison_document?.template_contract_path || ''}
/>
</div>
</div>
</div>
)}
{/* 重新上传模板模态框 */}
<Modal
isOpen={isReuploadModalOpen}
onClose={handleCloseReuploadModal}
title="重新上传模板"
size="medium"
footer={
<div className="flex justify-end space-x-3">
<Button type="default" onClick={handleCloseReuploadModal} disabled={isUploading}></Button>
<Button type="primary" onClick={handleConfirmUpload} disabled={selectedTemplateFiles.length === 0 || isUploading} icon={isUploading ? 'ri-loader-4-line animate-spin' : undefined}>
{isUploading ? '上传中...' : '确定上传'}
</Button>
</div>
}
>
<div className="space-y-4">
<div className="text-sm text-gray-600 mb-4">
<p></p>
<p className="mt-2 text-orange-600"><i className="ri-information-line mr-1" />PDF和Word格式的文件</p>
</div>
<UploadArea
ref={uploadAreaRef}
onFilesSelected={handleTemplateFilesSelected}
accept=".pdf,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
multiple={false}
icon="ri-file-text-line"
buttonText="选择模板文件"
mainText="点击或拖拽文件到此区域"
tipText={<span className="text-xs text-gray-500">.pdf | .docx</span>}
disabled={isUploading}
/>
{selectedTemplateFiles.length > 0 && (
<div className="mt-4">
<h4 className="text-sm font-medium text-gray-900 mb-2"></h4>
<div className="space-y-2">
{selectedTemplateFiles.map((file, index) => {
const fileName = file.name.toLowerCase();
const fileIcon = fileName.endsWith('.doc') || fileName.endsWith('.docx') ? 'ri-file-word-2-line' : 'ri-file-pdf-line';
const iconColor = fileName.endsWith('.doc') || fileName.endsWith('.docx') ? 'text-blue-600' : 'text-red-500';
return (
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
<div className="flex items-center">
<i className={`${fileIcon} ${iconColor} mr-2`} />
<div>
<div className="text-sm font-medium text-gray-900">{file.name}</div>
<div className="text-xs text-gray-500">{formatFileSize(file.size)}</div>
</div>
</div>
<button className="text-gray-400 hover:text-red-500 transition-colors" onClick={() => { setSelectedTemplateFiles(prev => prev.filter((_, i) => i !== index)); if (uploadAreaRef.current) uploadAreaRef.current.resetFileInput(); }} disabled={isUploading}>
<i className="ri-close-line" />
</button>
</div>
);
})}
</div>
</div>
)}
</div>
</Modal>
</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,
notApplicable: 0,
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: "本合同基本结构完整,主体内容清晰,但存在多处条款描述不完善的问题,主要体现在支付条件、违约责任、不可抗力、保密条款、合同终止条件等方面。这些问题虽不影响合同的基本合规性,但可能在合同履行过程中引发争议和纠纷。同时,合同签章不完整,也影响了合同的法律效力。建议对上述问题进行修改完善后再行签署。"
}
};
}