Files
leaudit-platform-frontend/app/routes/reviewsTest.tsx
T

1396 lines
54 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, 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<Statistics>;
reviewInfo?: Partial<ReviewInfo>;
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<Response> {
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);
const userRole = userInfo?.role || userInfo?.user_role || '';
if (!frontendJWT || !userRole) {
throw redirect('/login');
}
const { requireRoutePermission } = await import("~/api/auth/check-route-permission.server");
await requireRoutePermission('/reviewsTest', userRole, 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<ReviewsTestLoaderData>();
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<ReviewData | null>(null);
const [activeReviewPointResultId, setActiveReviewPointResultId] = useState<string | number | null>(null);
const [targetPage, setTargetPage] = useState<number | undefined>(undefined);
const [charPositions, setCharPositions] = useState<Array<{ box: number[][], char: string, score: number }> | undefined>(undefined);
const [bboxHighlight, setBboxHighlight] = useState<PdfBboxHighlight | 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] = 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 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 (
<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">{reviewData?.fileInfo?.fileName}</span>
</header>
<div className="flex-1 min-h-0 overflow-auto">
<Comparison comparison_document={comparison_document} />
</div>
</div>
);
}
if (isLoaderError) {
return (
<div className="flex items-center justify-center min-h-screen bg-slate-50 px-6">
<div className="w-full max-w-xl rounded-xl border border-slate-200 bg-white shadow-sm p-8">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-amber-50 text-amber-600 flex items-center justify-center shrink-0">
<i className="ri-error-warning-line text-xl" />
</div>
<div className="min-w-0 flex-1">
<h1 className="text-lg font-semibold text-slate-900"></h1>
<p className="mt-2 text-sm text-slate-600 leading-6 break-words">
{loaderData.message || '文档不存在、当前账号无权限访问,或评查数据尚未准备完成。'}
</p>
<div className="mt-5 flex items-center gap-3">
<button
type="button"
className="inline-flex items-center gap-2 rounded-md bg-[#00684a] px-4 py-2 text-sm font-medium text-white hover:bg-[#00543c]"
onClick={() => navigate(getReturnUrl())}
>
<i className="ri-arrow-left-line" />
</button>
<button
type="button"
className="inline-flex items-center gap-2 rounded-md border border-slate-200 px-4 py-2 text-sm text-slate-600 hover:bg-slate-50"
onClick={() => window.location.reload()}
>
<i className="ri-refresh-line" />
</button>
</div>
</div>
</div>
</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>
) : reviewData ? (
<main className="flex-1 min-h-0 grid grid-cols-[22%,1fr,30%] p-2">
{/* 左栏:规则目录 */}
<RulesDirectory
reviewPoints={reviewData.reviewPoints}
statistics={reviewData.statistics}
activeReviewPointResultId={activeReviewPointResultId}
fileName={reviewData.fileInfo.fileName}
onRuleSelect={handleRuleSelect}
onBack={() => navigate(getReturnUrl())}
/>
{/* 中栏:PDF 预览 */}
{/* 中栏:文件预览(根据文件类型切换) */}
<section className="flex flex-col min-h-0 bg-slate-100">
{previewExtension === 'docx' ? (
<DocxPreviewTest
filePath={previewPath}
targetPage={targetPage}
charPositions={charPositions}
activeReviewPointResultId={activeReviewPointResultId}
reviewPoints={reviewData.reviewPoints}
highlightValue={highlightValue}
aiSuggestionReplace={aiSuggestionReplace}
userInfo={currentUserInfo || undefined}
/>
) : (
<PdfPreviewTest
filePath={previewPath}
targetPage={targetPage}
charPositions={charPositions}
bboxHighlight={bboxHighlight}
activeReviewPointResultId={activeReviewPointResultId}
reviewPoints={reviewData.reviewPoints}
/>
)}
</section>
{/* 右栏:详情面板 */}
<DetailPanel
activeTab={rightActiveTab}
onTabChange={handleTabChange}
activeReviewPoint={activeReviewPoint}
reviewPoints={reviewData.reviewPoints}
detailMode={detailMode}
fileInfo={reviewData.fileInfo}
reviewInfo={reviewData.reviewInfo}
onReviewPointSelect={handleReviewPointSelect}
onStatusChange={handleReviewPointStatusChange}
onConfirmResults={handleConfirmResults}
onDownload={handleDownloadFile}
auditStatus={document?.auditStatus}
fileFormat={reviewData.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={previewPath}
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: "本合同基本结构完整,主体内容清晰,但存在多处条款描述不完善的问题,主要体现在支付条件、违约责任、不可抗力、保密条款、合同终止条件等方面。这些问题虽不影响合同的基本合规性,但可能在合同履行过程中引发争议和纠纷。同时,合同签章不完整,也影响了合同的法律效力。建议对上述问题进行修改完善后再行签署。"
}
};
}