/** * 评查详情页面 * * 功能概述: * - 显示文档评查结果和详细信息 * - 支持查看文档内容及评查点高亮标记 * - 提供评查点列表,分为通过、警告和错误三种类型 * - 支持评查点处理,如一键替换、人工审核等功能 * - 支持导出评查报告和下载原文件 * * 组件结构: * - FileInfo: 显示文件基本信息和操作按钮 * - ReviewTabs: 页面选项卡,包括评查结果、AI智能分析和文件信息 * - FilePreview: 文档预览组件,显示文档内容及高亮问题 * - ReviewPointsList: 评查点列表组件,显示所有评查结果 * - AIAnalysis: AI智能分析结果,提供综合评价 * - FileDetails: 文件详情信息 * * 数据流转: * 1. 页面加载时从API获取评查详情数据 * 2. 根据评查点ID关联文档中的高亮区域 * 3. 点击评查点时在文档中定位对应位置 * 4. 处理评查点时更新状态并反馈到UI * * @author 中国烟草AI合同及卷宗审核系统开发团队 */ import { type MetaFunction, type LoaderFunctionArgs, type ActionFunctionArgs, redirect } from "@remix-run/node"; import { useState, useEffect, useRef } from "react"; import { useNavigate, useLoaderData, useFetcher } from "@remix-run/react"; import reviewsStyles from "~/styles/reviews.css?url"; import { getReviewPoints_fromApi, 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, type PdfBboxHighlight } from '~/components/reviews'; 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; } type PreviewKind = 'pdf' | 'docx'; type PreviewDocument = { path?: string; attachments?: Array<{ fileRole?: string; ossUrl?: string; }>; }; interface DefaultPreviewTarget { page?: number; highlightValue?: string; bboxHighlight?: PdfBboxHighlight; } function resolvePreviewPath(document: PreviewDocument | null | undefined): string { if (document?.path) return document.path; const primaryAttachment = Array.isArray(document?.attachments) ? document.attachments.find((item) => item?.fileRole === 'primary' && item?.ossUrl) : null; return primaryAttachment?.ossUrl || ''; } function resolvePreviewExtension(document: PreviewDocument | null | undefined): string { const path = resolvePreviewPath(document); const suffix = path.split('.').pop(); return typeof suffix === 'string' ? suffix.toLowerCase() : ''; } function isValidQuad(value: unknown): value is [number, number, number, number] { return Array.isArray(value) && value.length === 4 && value.every(item => typeof item === 'number' && Number.isFinite(item)); } function hasNonZeroQuad(value: [number, number, number, number]): boolean { return value.some(item => item !== 0); } function getReviewPointContentText(value: unknown): string | undefined { if (value == null) return undefined; if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { const text = String(value).trim(); return text || undefined; } if (typeof value === 'object' && value && 'value' in value) { return getReviewPointContentText((value as { value?: unknown }).value); } return undefined; } function getReviewPointFieldPage(point: ReviewPoint, fieldKey: string, rawValue: unknown): number | undefined { const contentPage = point.contentPage?.[fieldKey]; const normalizedContentPage = Number(contentPage); if (Number.isFinite(normalizedContentPage) && normalizedContentPage > 0) return normalizedContentPage; const inlinePage = typeof rawValue === 'object' && rawValue && 'page' in rawValue ? Number((rawValue as { page?: unknown }).page) : NaN; if (Number.isFinite(inlinePage) && inlinePage > 0) return inlinePage; const pageNum = point.fieldPositions?.[fieldKey]?.page_num; if (typeof pageNum === 'number' && Number.isFinite(pageNum) && pageNum >= 0) return pageNum + 1; return undefined; } function getReviewPointFieldBbox(point: ReviewPoint, fieldKey: string, page?: number): PdfBboxHighlight | undefined { const fieldPosition = point.fieldPositions?.[fieldKey]; if (!fieldPosition || !isValidQuad(fieldPosition.bbox) || !isValidQuad(fieldPosition.page_box)) return undefined; if (!hasNonZeroQuad(fieldPosition.bbox) || !hasNonZeroQuad(fieldPosition.page_box)) return undefined; return { fieldKey, bbox: [...fieldPosition.bbox], pageBox: [...fieldPosition.page_box], pageNum: fieldPosition.page_num, page, confidence: fieldPosition.confidence, matchMethod: fieldPosition.match_method, }; } function resolveDefaultPreviewTarget(point: ReviewPoint, previewKind: PreviewKind): DefaultPreviewTarget { let firstPageCandidate: DefaultPreviewTarget | undefined; let firstPageWithBboxCandidate: DefaultPreviewTarget | undefined; let firstPageWithTextCandidate: DefaultPreviewTarget | undefined; for (const [fieldKey, rawValue] of Object.entries(point.content || {})) { const page = getReviewPointFieldPage(point, fieldKey, rawValue); if (!page) continue; const highlightValue = getReviewPointContentText(rawValue); const bboxHighlight = getReviewPointFieldBbox(point, fieldKey, page); const candidate: DefaultPreviewTarget = { page, highlightValue, bboxHighlight }; if (!firstPageCandidate) firstPageCandidate = candidate; if (!firstPageWithBboxCandidate && bboxHighlight) firstPageWithBboxCandidate = candidate; if (!firstPageWithTextCandidate && highlightValue) firstPageWithTextCandidate = candidate; } if (previewKind === 'pdf') { return firstPageWithBboxCandidate || firstPageCandidate || {}; } return firstPageWithTextCandidate || firstPageCandidate || {}; } interface ReviewsTestLoaderSuccess { previousRoute: string; document: any; reviewPoints: ReviewPoint[]; reviewInfo: ReviewInfo; statistics: Statistics; comparison_document: any; userInfo: { sub: string; nick_name: string } | null; frontendJWT: string | null; flowType: 'legacy' | 'leaudit'; detailMode: 'legacy' | 'leaudit'; } interface ReviewsTestLoaderError { result: false; message: string; previousRoute: string; } type ReviewsTestLoaderData = ReviewsTestLoaderSuccess | ReviewsTestLoaderError; interface NestedReviewPayload { data?: ReviewPoint[]; stats?: Partial; reviewInfo?: Partial; document?: unknown; comparison_document?: unknown; } const EMPTY_STATISTICS: Statistics = { total: 0, success: 0, warning: 0, error: 0, notApplicable: 0, score: 0, }; const EMPTY_REVIEW_INFO: ReviewInfo = { reviewTime: '', reviewModel: '', ruleGroup: '', result: '', issueCount: 0, }; function isReviewsTestLoaderError(data: ReviewsTestLoaderData): data is ReviewsTestLoaderError { return 'result' in data && data.result === false; } function getNestedReviewPayload(value: unknown): NestedReviewPayload | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; const candidate = value as NestedReviewPayload; if (!('data' in candidate) && !('document' in candidate) && !('stats' in candidate)) return null; return candidate; } function getReviewPointsArray(value: unknown): ReviewPoint[] { return Array.isArray(value) ? (value as ReviewPoint[]) : []; } 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): Promise { const url = new URL(request.url); const id = url.searchParams.get('id') || ''; const previousRoute = url.searchParams.get('previousRoute') || ''; try { if (!id) { return Response.json({ result: false, message: '文件ID不能为空', previousRoute }); } const { getUserSession } = await import("~/api/login/auth.server"); const { userInfo, frontendJWT } = await getUserSession(request); if (!frontendJWT || !userInfo?.role) { throw redirect('/login'); } const { requireRoutePermission } = await import("~/api/auth/check-route-permission.server"); await requireRoutePermission('/reviewsTest', userInfo.role, frontendJWT); const reviewData = await getReviewPoints_fromApi(id, request); if ('error' in reviewData && reviewData.error) { return Response.json({ result: false, message: reviewData.error, previousRoute, }); } return Response.json({ previousRoute, document: reviewData.document, reviewPoints: reviewData.data, reviewInfo: reviewData.reviewInfo, statistics: { ...reviewData.stats, notApplicable: reviewData.stats?.notApplicable ?? 0 }, comparison_document: reviewData.comparison_document, userInfo: userInfo?.sub && userInfo?.nick_name ? { sub: userInfo.sub, nick_name: userInfo.nick_name } : null, frontendJWT: frontendJWT ?? null, flowType: 'leaudit', detailMode: 'leaudit', }); } catch (error) { if (error instanceof Response) { if (error.status === 401) { throw redirect('/login'); } if (error.status === 403) { return Response.json({ result: false, message: '当前账号没有评查详情访问权限,请联系管理员开通文档查看权限。', previousRoute, }, { status: 403 }); } } console.error('[reviewsTest loader] Failed to load review data:', error); return Response.json({ result: false, message: `获取评查数据失败: ${error instanceof Error ? error.message : '未知错误'}`, previousRoute, }); } } 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(); const fetcher = useFetcher(); const isLoaderError = isReviewsTestLoaderError(loaderData); const successLoaderData = isLoaderError ? null : loaderData; const nestedReviewPayload = getNestedReviewPayload(successLoaderData?.reviewPoints); const directReviewPoints = getReviewPointsArray(successLoaderData?.reviewPoints); const nestedReviewPoints = getReviewPointsArray(nestedReviewPayload?.data); const document = successLoaderData?.document ?? nestedReviewPayload?.document ?? null; const reviewPoints = directReviewPoints.length > 0 ? directReviewPoints : nestedReviewPoints; const statistics = { ...EMPTY_STATISTICS, ...(nestedReviewPayload?.stats ?? null), ...(successLoaderData?.statistics ?? null), }; const reviewInfo = { ...EMPTY_REVIEW_INFO, ...(nestedReviewPayload?.reviewInfo ?? null), ...(successLoaderData?.reviewInfo ?? null), }; const comparison_document = successLoaderData?.comparison_document ?? nestedReviewPayload?.comparison_document ?? null; const detailMode = successLoaderData?.detailMode ?? 'legacy'; const currentUserInfo = successLoaderData?.userInfo ?? null; const frontendJWT = successLoaderData?.frontendJWT ?? null; const [isLoading, setIsLoading] = useState(false); // 已经通过loader加载了数据,不需要再显示加载状态 const [rightActiveTab, setRightActiveTab] = useState<'result' | 'fields' | 'info'>('result'); const [reviewData, setReviewData] = useState(null); const [activeReviewPointResultId, setActiveReviewPointResultId] = useState(null); const [targetPage, setTargetPage] = useState(undefined); const [charPositions, setCharPositions] = useState | undefined>(undefined); const [bboxHighlight, setBboxHighlight] = useState(undefined); const [highlightValue, setHighlightValue] = useState(undefined); const [pendingUpdate, setPendingUpdate] = useState<{ reviewPointResultId: string; newStatus: string; message: string; } | null>(null); const [showCompareOverlay, setShowCompareOverlay] = useState(false); // 一键替换(DOCX Collabora 使用) const [aiSuggestionReplace] = useState<{ searchText: string; replaceText: string; pageNumber: number; silentReplace?: boolean; } | undefined>(undefined); // 模板上传相关状态 const [isReuploadModalOpen, setIsReuploadModalOpen] = useState(false); const [selectedTemplateFiles, setSelectedTemplateFiles] = useState([]); const [isUploading, setIsUploading] = useState(false); const [showComparison, setShowComparison] = useState(false); const uploadAreaRef = useRef(null); const previewPath = resolvePreviewPath(document); const previewExtension = resolvePreviewExtension(document); // 结构比对按钮显示条件: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.groupEnd(); // } // }, [loaderData, document, reviewPoints, statistics, reviewInfo, comparison_document]); // loader 数据加载出错 useEffect(()=>{ loadingBarService.hide(); },[loaderData]); // 当文档 ID 变化时,清空高亮相关的状态 useEffect(() => { if (document?.id) { setActiveReviewPointResultId(null); setTargetPage(undefined); setCharPositions(undefined); setBboxHighlight(undefined); setHighlightValue(undefined); } }, [document?.id]); // 模拟获取评查数据 useEffect(() => { if (!document) return; // 构建文件信息对象 const 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 || {}, // 文件类型(1:合同,2:卷宗。。。) fileType: document.type || document.type_id ? document.type_id.toString() : '' }; // 创建包含真实文档数据的评查数据对象 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 handleTabChange = (tabKey: 'result' | 'fields' | 'info') => { setRightActiveTab(tabKey); }; // 从左栏选择评查点 const handleRuleSelect = (id: string | number) => { setRightActiveTab('result'); const point = reviewData?.reviewPoints.find(p => p.id === id); if (point) { console.log('选择的评查点:', point); const previewKind: PreviewKind = previewExtension === 'docx' ? 'docx' : 'pdf'; const defaultTarget = resolveDefaultPreviewTarget(point, previewKind); handleReviewPointSelect( id, defaultTarget.page, undefined, defaultTarget.highlightValue, previewKind === 'pdf' ? defaultTarget.bboxHighlight : undefined, ); return; } handleReviewPointSelect(id); }; const handleDownloadFile = async () => { if (!previewPath) { toastService.warning('当前文档暂无可下载原文件'); return; } try { const downloadUrl = `/api/pdf-proxy?path=${encodeURIComponent(previewPath)}`; 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(previewPath.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 | number, page?: number, charPos?: Array<{ box: number[][], char: string, score: number }>, value?: string, nextBboxHighlight?: PdfBboxHighlight) => { // 如果点击的是相同的评查点,但有page参数,先重置targetPage以确保useEffect能够触发 if (reviewPointId === activeReviewPointResultId && page) { setTargetPage(undefined); setCharPositions(undefined); setBboxHighlight(undefined); setHighlightValue(undefined); // 使用setTimeout确保状态更新后再设置新的targetPage、charPositions和highlightValue setTimeout(() => { setActiveReviewPointResultId(reviewPointId); setTargetPage(page); setCharPositions(charPos); setBboxHighlight(nextBboxHighlight); setHighlightValue(value); }, 0); } else { // 正常设置activeReviewPointId、targetPage、charPositions和highlightValue setActiveReviewPointResultId(reviewPointId); setTargetPage(page); setCharPositions(charPos); setBboxHighlight(nextBboxHighlight); 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(getReturnUrl()); } 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 = reviewData?.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); } }; // ── 结构比对全页面视图 ── if (showComparison) { return (
{reviewData?.fileInfo?.fileName}
); } if (isLoaderError) { return (

评查详情暂时无法打开

{loaderData.message || '文档不存在、当前账号无权限访问,或评查数据尚未准备完成。'}

); } return (
{isLoading ? (
加载中...
) : reviewData ? (
{/* 左栏:规则目录 */} navigate(getReturnUrl())} /> {/* 中栏:PDF 预览 */} {/* 中栏:文件预览(根据文件类型切换) */}
{previewExtension === 'docx' ? ( ) : ( )}
{/* 右栏:详情面板 */} setShowComparison(true)} showComparisonButton={showComparisonButton} />
) : null} {/* 结构比对 overlay */} {showCompareOverlay && comparison_document && (
结构比对
)} {/* 重新上传模板模态框 */}
} >

请选择新的模板文件用于结构比对。

注意:支持PDF和Word格式的文件,上传后将替换当前的比对模板。

支持格式:.pdf | .docx} disabled={isUploading} /> {selectedTemplateFiles.length > 0 && (

已选择的文件:

{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 (
{file.name}
{formatFileSize(file.size)}
); })}
)}
); } // 模拟评查数据 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: "本合同基本结构完整,主体内容清晰,但存在多处条款描述不完善的问题,主要体现在支付条件、违约责任、不可抗力、保密条款、合同终止条件等方面。这些问题虽不影响合同的基本合规性,但可能在合同履行过程中引发争议和纠纷。同时,合同签章不完整,也影响了合同的法律效力。建议对上述问题进行修改完善后再行签署。" } }; }