feat: 1. 重构交叉评查任务的文档列表的显示,对接接口查询当前任务的文档相关信息。

2.文档上传通过接口去查询是否存在同名的文件,做上传前拦截提示。
3.交叉评查的评查结果也同步添加企查查的企业信息查询模块。
4. 封装上传附件和上传模板的模态框的组件,在交叉评查的文档列表中引入显示。
5. 交叉评查的评查结果中关于合同类型的文档同步加入结构比对的功能。
This commit is contained in:
2025-12-13 07:18:37 +08:00
parent daa53289af
commit 1658bb1c6f
11 changed files with 3368 additions and 363 deletions
@@ -1,15 +1,21 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { Link } from "@remix-run/react";
import { Modal } from '../ui/Modal';
import { Table } from '../ui/Table';
import { Button } from '../ui/Button';
import { FileIcon } from '../ui/FileIcon';
import { FileTag } from '../ui/FileTag';
import { FileTypeTag } from '../ui/FileTypeTag';
import { StatusBadge } from '../ui/StatusBadge';
import { Pagination } from '../ui/Pagination';
import { LoadingIndicator } from '../ui/SkeletonScreen';
import { updateDocumentAuditStatus, type TaskDocument } from '~/api/cross-checking/cross-files'; // 更新导入
import { LoadingIndicator, NumberSkeleton, TableRowSkeleton } from '../ui/SkeletonScreen';
import { ResultStats } from '../ui/ResultStats';
import { toastService } from '../ui/Toast';
import { AttachmentUploadModal } from '../ui/AttachmentUploadModal';
import { TemplateUploadModal } from '../ui/TemplateUploadModal';
import { formatDate } from '~/utils';
import {useRef, useState} from "react";
import {
type CrossReviewDocumentWithVersion,
type CrossReviewHistoryVersion,
appendTaskDocumentAttachments,
uploadCrossReviewDocumentTemplate,
} from '~/api/cross-checking/cross-files';
// 导出样式链接
export const links = () => [];
@@ -18,81 +24,283 @@ interface DocumentListModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
files: TaskDocument[]; // 更新类型
/** 文档列表(新版接口数据) */
documents: CrossReviewDocumentWithVersion[];
/** 查看文件回调 */
onViewFile?: (fileId: string) => void;
/** 加载中状态 */
loading?: boolean;
// 分页相关属性
/** 当前页码 */
currentPage?: number;
/** 每页条数 */
pageSize?: number;
/** 总数 */
total?: number;
/** 页码变更回调 */
onPageChange?: (page: number) => void;
/** 每页条数变更回调 */
onPageSizeChange?: (size: number) => void;
frontendJWT?: string; // 新增JWT参数
/** 搜索回调 */
onSearch?: (keyword: string) => void;
/** 任务ID(用于追加附件等操作) */
taskId?: number;
/** 任务名称 */
taskName?: string;
/** JWT Token */
frontendJWT?: string;
/** 是否是负责人(任务创建者或主要负责人) */
isProposer?: boolean;
/** 负责人状态是否加载中 */
isProposerLoading?: boolean;
}
// 文件处理状态选项
const fileProcessingStatusOptions = [
{ value: "Waiting", label: "上传中", icon: "ri-loader-line", color: "blue" },
{ value: "Cutting", label: "切分中", icon: "ri-loader-line", color: "purple" },
{ value: "Extractioning", label: "抽取中", icon: "ri-loader-line", color: "cyan" },
{ value: "Evaluationing", label: "评查中", icon: "ri-loader-line", color: "teal" },
{ value: "Failed", label: "抽取异常", icon: "ri-close-circle-line", color: "red" },
{ value: "Processed", label: "已完成", icon: "ri-check-line", color: "green" },
];
// 交叉评查审核状态选项(0=未评查, 1=已评查)
const crossReviewAuditStatusMapping: Record<string, { label: string; color: string; icon: string }> = {
"0": { label: "未评查", color: "blue", icon: "ri-time-line" },
"1": { label: "已评查", color: "green", icon: "ri-check-line" },
};
// 格式化文件大小
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];
};
export function DocumentListModal({
isOpen,
onClose,
title,
files,
documents,
onViewFile,
loading = false,
// 分页属性,使用默认值
currentPage = 1,
pageSize = 10,
total = 0,
onPageChange,
onPageSizeChange,
frontendJWT
onSearch,
taskId,
taskName,
frontendJWT,
isProposer = false,
isProposerLoading = false
}: DocumentListModalProps) {
// 搜索关键词
const [searchKeyword, setSearchKeyword] = useState('');
// 防抖定时器
const searchDebounceRef = useRef<number | null>(null);
// 查看按钮防抖
const [isnavigating,setIsnavigating] = useState(false)
const viewDebounceRef = useRef<number | null>(null)
const handleViewClickDebounced = (fileId: string, auditStatus: number | null) => {
if(viewDebounceRef.current) return;
viewDebounceRef.current = window.setTimeout(()=>{
viewDebounceRef.current = null;
},1000);
void handleReviewFileClick(fileId, auditStatus);
}
// 查看评查文件
const handleReviewFileClick = async (fileId: string, auditStatus: number | null) => {
// 检查audit_status是否为0,如果是则更新为2
if (auditStatus === 0 || auditStatus === null) {
try {
// 更新文档状态,传递JWT
const updatedFile = await updateDocumentAuditStatus(fileId, 2, frontendJWT);
// console.log('更新后的文档状态:', updatedFile);
} catch (error) {
console.error('更新文件审核状态时出错:', error);
toastService.error(`更新文件审核状态时出错:${error instanceof Error ? error.message : '未知错误'}`);
return;
}
const [isNavigating, setIsNavigating] = useState(false);
const viewDebounceRef = useRef<number | null>(null);
// 版本展开状态
const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set());
// 本地文档数据(用于管理展开状态)
const [localDocuments, setLocalDocuments] = useState<CrossReviewDocumentWithVersion[]>([]);
// 附件追加模态框状态
const [showAttachmentUpload, setShowAttachmentUpload] = useState(false);
const [selectedDocumentId, setSelectedDocumentId] = useState<number | null>(null);
const [selectedDocumentName, setSelectedDocumentName] = useState<string | null>(null);
const [selectedDocumentVersion, setSelectedDocumentVersion] = useState<number | null>(null);
const [selectedDocumentPath, setSelectedDocumentPath] = useState<string | null>(null);
const [attachmentUploading, setAttachmentUploading] = useState(false);
// 模板上传模态框状态
const [showTemplateUpload, setShowTemplateUpload] = useState(false);
const [templateUploading, setTemplateUploading] = useState(false);
// 同步外部文档数据到本地
useEffect(() => {
setLocalDocuments(documents.map(doc => ({ ...doc, isExpanded: expandedRows.has(doc.id) })));
}, [documents, expandedRows]);
// 处理搜索
const handleSearchChange = useCallback((value: string) => {
setSearchKeyword(value);
// 清除之前的防抖定时器
if (searchDebounceRef.current) {
clearTimeout(searchDebounceRef.current);
}
// 如果有自定义的查看处理函数,则调用它
// 设置新的防抖定时器(300ms
searchDebounceRef.current = window.setTimeout(() => {
onSearch?.(value);
}, 300);
}, [onSearch]);
// 清理防抖定时器
useEffect(() => {
return () => {
if (searchDebounceRef.current) {
clearTimeout(searchDebounceRef.current);
}
};
}, []);
// 查看文件(带防抖)
const handleViewClickDebounced = (fileId: string) => {
if (viewDebounceRef.current) return;
viewDebounceRef.current = window.setTimeout(() => {
viewDebounceRef.current = null;
}, 1000);
if (onViewFile) {
setIsnavigating(true)
setIsNavigating(true);
onViewFile(fileId);
}
};
// 审核状态选项及样式 - 与documents._index.tsx保持一致
const auditStatusMapping: Record<string, { label: string; color: string; icon: string }> = {
"-1": { label: "不通过", color: "red", icon: "ri-close-line" },
"-2": { label: "警告", color: "yellow", icon: "ri-alert-line" },
"0": { label: "待审核", color: "blue", icon: "ri-time-line" },
"1": { label: "通过", color: "green", icon: "ri-check-line" },
"2": { label: "审核中", color: "purple", icon: "ri-search-line" },
// 展开/折叠历史版本
const handleToggleExpand = (doc: CrossReviewDocumentWithVersion) => {
const newExpanded = new Set(expandedRows);
if (expandedRows.has(doc.id)) {
// 折叠
newExpanded.delete(doc.id);
} else {
// 检查是否有历史版本
if (!doc.history_versions || doc.history_versions.length === 0) {
return;
}
// 展开
newExpanded.add(doc.id);
}
setExpandedRows(newExpanded);
setLocalDocuments(prevDocs =>
prevDocs.map(d =>
d.id === doc.id ? { ...d, isExpanded: newExpanded.has(doc.id) } : d
)
);
};
// 渲染审核状态
const renderAuditStatus = (file: TaskDocument) => {
// 处理audit_status为null或undefined的情况,默认为0(待审核)
const auditStatus = file.audit_status != null ? file.audit_status : 0;
// 打开追加附件模态框
const handleOpenAttachmentUpload = (doc: CrossReviewDocumentWithVersion | CrossReviewHistoryVersion, version?: number) => {
setSelectedDocumentId(doc.id);
setSelectedDocumentName('name' in doc ? doc.name : `v${doc.version_number} 版本`);
setSelectedDocumentVersion(version ?? ('version_number' in doc ? doc.version_number : null));
setSelectedDocumentPath(doc.path);
setShowAttachmentUpload(true);
};
// 打开上传模板模态框
const handleOpenTemplateUpload = (doc: CrossReviewDocumentWithVersion | CrossReviewHistoryVersion, version?: number) => {
setSelectedDocumentId(doc.id);
setSelectedDocumentName('name' in doc ? doc.name : `v${doc.version_number} 版本`);
setSelectedDocumentVersion(version ?? ('version_number' in doc ? doc.version_number : null));
setShowTemplateUpload(true);
};
// 关闭模态框的通用处理
const handleCloseModals = () => {
setShowAttachmentUpload(false);
setShowTemplateUpload(false);
setSelectedDocumentId(null);
setSelectedDocumentName(null);
setSelectedDocumentVersion(null);
setSelectedDocumentPath(null);
};
// 处理追加附件上传
const handleAttachmentUpload = async (files: File[], _mergeMode: 'overwrite' | 'new', remark: string) => {
if (!taskId || !selectedDocumentId) {
toastService.error('任务ID或文档ID无效');
return;
}
try {
setAttachmentUploading(true);
const result = await appendTaskDocumentAttachments({
taskId,
documentId: selectedDocumentId,
files,
remark: remark || undefined,
jwtToken: frontendJWT
});
if (!result.success || result.error) {
throw new Error(result.error || '追加附件失败');
}
toastService.success('附件追加成功!新版本正在后台处理中');
handleCloseModals();
// 触发重新加载文档列表
if (onSearch) {
onSearch(searchKeyword);
}
} catch (error) {
console.error('追加附件失败:', error);
toastService.error(error instanceof Error ? error.message : '追加附件失败');
} finally {
setAttachmentUploading(false);
}
};
// 处理模板上传
const handleTemplateUpload = async (file: File) => {
if (!selectedDocumentId) {
toastService.error('文档ID无效');
return;
}
try {
setTemplateUploading(true);
const result = await uploadCrossReviewDocumentTemplate({
documentId: selectedDocumentId,
file,
jwtToken: frontendJWT
});
if (!result.success || result.error) {
throw new Error(result.error || '上传模板失败');
}
toastService.success('合同模板上传成功!');
handleCloseModals();
} catch (error) {
console.error('上传模板失败:', error);
toastService.error(error instanceof Error ? error.message : '上传模板失败');
} finally {
setTemplateUploading(false);
}
};
// 渲染文件处理状态
const renderFileStatus = (status: string) => {
const statusInfo = fileProcessingStatusOptions.find(s => s.value === status) || fileProcessingStatusOptions[0];
const isSpinning = status !== "Processed" && status !== "Failed";
return (
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs bg-${statusInfo.color}-100 text-${statusInfo.color}-800`}>
<i className={`${statusInfo.icon} ${isSpinning ? "animate-spin" : ""} mr-1`}></i>
<span>{statusInfo.label}</span>
</div>
);
};
// 渲染审核状态(交叉评查专用:0=未评查, 1=已评查)
const renderAuditStatus = (auditStatus: 0 | 1) => {
const statusKey = auditStatus.toString();
const statusInfo = auditStatusMapping[statusKey] || auditStatusMapping["0"];
const statusInfo = crossReviewAuditStatusMapping[statusKey] || crossReviewAuditStatusMapping["0"];
return (
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs bg-${statusInfo.color}-100 text-${statusInfo.color}-800`}>
<i className={`${statusInfo.icon} mr-1`}></i>
@@ -101,129 +309,211 @@ export function DocumentListModal({
);
};
// 获取文件大小的友好显示
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
// 渲染历史版本行
const renderHistoryRow = (historyDoc: CrossReviewHistoryVersion, parentDoc: CrossReviewDocumentWithVersion) => {
return (
<tr key={`history-${historyDoc.id}`} className="history-row bg-gray-50/50">
<td className="align-middle px-4 py-3" style={{ width: '25%' }}>
<div className="flex items-center gap-3 pl-6">
<i className="ri-history-line text-gray-400 text-lg"></i>
<span className="history-version-label text-sm text-gray-600">
v{historyDoc.version_number}
</span>
{historyDoc.document_number && (
<span className="history-version-label text-sm text-gray-500">
{historyDoc.document_number}
</span>
)}
</div>
</td>
<td className="text-xs text-gray-600 px-4 py-3" style={{ width: '8%' }}>
{formatFileSize(historyDoc.file_size)}
</td>
<td className="px-4 py-3" style={{ width: '8%' }}>
{renderFileStatus(historyDoc.status)}
</td>
<td className="px-4 py-3" style={{ width: '8%' }}>
{renderAuditStatus(historyDoc.audit_status)}
</td>
<td className="px-4 py-3" style={{ width: '15%' }}>
<ResultStats
passCount={historyDoc.pass_count}
warningCount={historyDoc.warning_count}
errorCount={historyDoc.error_count}
manualCount={historyDoc.manual_count}
warningMessages={historyDoc.warning_messages}
errorMessages={historyDoc.error_messages}
manualMessages={historyDoc.manual_messages}
/>
</td>
<td className="px-4 py-3" style={{ width: '8%' }}>
<div className="text-left">
{historyDoc.score_percent != null ? (
<span className={`font-medium ${
historyDoc.score_percent >= 90 ? 'text-green-600' :
historyDoc.score_percent >= 70 ? 'text-yellow-600' :
historyDoc.score_percent >= 0 ? 'text-red-600' : 'text-gray-400'
}`}>
{historyDoc.score_percent.toFixed(1)}%
</span>
) : (
<span className="text-gray-400">-</span>
)}
</div>
</td>
<td className="text-xs text-gray-600 px-4 py-3" style={{ width: '10%' }}>
{formatDate(historyDoc.upload_time).split(' ')[0]}
<br />
<span className="text-gray-400">{formatDate(historyDoc.upload_time).split(' ')[1]}</span>
</td>
<td className="px-4 py-3" style={{ width: '13%' }}>
<div className="flex flex-wrap gap-1">
{/* 查看按钮 */}
<Link
to={`/cross-checking/review?id=${historyDoc.id}&taskId=${taskId}`}
className={`text-xs px-2 py-1 h-7 mr-1 ${
historyDoc.status === 'Processed'
? 'hover:underline text-primary'
: 'text-gray-400 pointer-events-none'
}`}
>
<i className="ri-eye-line mr-1"></i>
</Link>
{/* 追加附件按钮 - 仅当 type_name 包含"合同"时显示 */}
{historyDoc.status === 'Processed' && taskId && parentDoc.type_name?.includes('合同') && (
<button
type="button"
className="text-xs px-2 py-1 h-7 mr-1 text-gray-500 hover:underline hover:text-gray-700"
onClick={() => handleOpenAttachmentUpload(historyDoc)}
>
<i className="ri-attachment-line mr-1"></i>
</button>
)}
{/* 上传模板按钮 - 仅当 type_name 包含"合同"时显示 */}
{historyDoc.status === 'Processed' && parentDoc.type_name?.includes('合同') && (
<button
type="button"
className="text-xs px-2 py-1 h-7 text-gray-500 hover:underline hover:text-gray-700"
onClick={() => handleOpenTemplateUpload(historyDoc)}
>
<i className="ri-file-copy-line mr-1"></i>
</button>
)}
</div>
</td>
</tr>
);
};
// 定义表格列配置
// 表格列定义
const columns = [
{
title: "文名称",
key: "fileName",
width: "30%",
render: (_: unknown, file: TaskDocument) => (
<div className="flex">
<div className="flex-shrink-0 flex items-center self-center">
<FileIcon fileName={file.file_name} className="text-lg w-10 h-10" />
</div>
<div className="min-w-0 flex-1 flex flex-col py-2 ml-2">
<div className="font-normal text-base break-words whitespace-normal leading-normal" title={file.file_name}>{file.file_name}</div>
<div className="text-xs text-secondary mt-2">
{file.file_code}
</div>
<div className="text-xs text-secondary mt-1">
{formatFileSize(file.file_size)}
title: "文名称",
key: "name",
width: "25%",
render: (_: unknown, record: CrossReviewDocumentWithVersion) => (
<div className="flex items-center gap-3">
{/* 展开/折叠图标(仅在有历史版本时显示) */}
{record.total_versions > 1 ? (
<i
className={`ri-arrow-right-s-line expand-icon cursor-pointer transition-transform ${expandedRows.has(record.id) ? 'rotate-90' : ''}`}
onClick={(e) => {
e.stopPropagation();
handleToggleExpand(record);
}}
title={expandedRows.has(record.id) ? '折叠历史版本' : '展开历史版本'}
></i>
) : (
<span style={{ width: '20px', display: 'inline-block' }}></span>
)}
<FileTag
extension={record.name.split('.').pop() || ''}
showIcon={true}
showText={false}
showBackground={false}
size="lg"
className="flex-shrink-0"
/>
<div className="flex flex-col gap-2 flex-1 min-w-0">
<span className="doc-name-text font-medium text-gray-900" title={record.name}>
{record.name}
</span>
{record.document_number && (
<span className="document-number text-xs text-gray-500">{record.document_number}</span>
)}
<div className="flex items-center gap-2 flex-wrap">
<FileTypeTag
type={record.type_id.toString()}
typeName={record.type_name}
text={record.type_name}
size="sm"
showIcon={false}
colorMode="light"
/>
{/* 版本徽章 */}
{record.total_versions > 1 && (
<span className="version-badge text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded">
<i className="ri-history-line mr-1"></i>
v{record.version_number} ({record.total_versions - 1})
</span>
)}
</div>
</div>
</div>
)
},
{
title: "文件类型",
key: "fileType",
title: "文件大小",
key: "size",
width: "8%",
render: (_: unknown, file: TaskDocument) => (
<FileTypeTag
type="other"
typeName={file.file_type_name}
text={file.file_type_name}
size="sm"
showIcon={false}
colorMode="light"
render: (_: unknown, record: CrossReviewDocumentWithVersion) => formatFileSize(record.file_size)
},
{
title: "文件状态",
key: "status",
width: "8%",
render: (_: unknown, record: CrossReviewDocumentWithVersion) => renderFileStatus(record.status)
},
{
title: "评查状态",
key: "auditStatus",
width: "8%",
render: (_: unknown, record: CrossReviewDocumentWithVersion) => renderAuditStatus(record.audit_status)
},
{
title: "结果统计",
key: "resultStats",
width: "15%",
render: (_: unknown, record: CrossReviewDocumentWithVersion) => (
<ResultStats
passCount={record.pass_count}
warningCount={record.warning_count}
errorCount={record.error_count}
manualCount={record.manual_count}
warningMessages={record.warning_messages}
errorMessages={record.error_messages}
manualMessages={record.manual_messages}
/>
)
},
{
title: "上传时间",
key: "uploadTime",
title: "评查分数百分比",
key: "scorePercent",
width: "8%",
render: (_: unknown, file: TaskDocument) => {
const uploadTime = formatDate(file.upload_time).split(' ');
const date = uploadTime[0];
const time = uploadTime[1];
return (
<div>
<span className="text-base">{date}</span> {/* 2025-07-22 */}
<br />
<span className="text-xs text-secondary">{time}</span> {/* 10:00:00 */}
</div>
);
}
},
{
title: "评查统计",
key: "reviewStatus",
width: "10%",
render: (_: unknown, file: TaskDocument) =>
// 要文件切分处理完之后,再显示评查统计
file.status === 'Processed' ? (
<div>
{file.pass_count > 0 && (
<StatusBadge
status="pass"
text={`通过(${file.pass_count})`}
showIcon={true}
className="my-2"
/>
)}
{file.warning_count > 0 && (
<StatusBadge
status="warning"
text={`警告(${file.warning_count})`}
showIcon={true}
className="my-2"
/>
)}
{file.fail_count > 0 && (
<StatusBadge
status="fail"
text={`不通过(${file.fail_count})`}
showIcon={true}
className="my-2"
/>
)}
{/* {file.manual_count > 0 && (
<StatusBadge
status="pending"
text={`需人工(${file.manual_count})`}
showIcon={true}
className="my-2"
/>
)} */}
</div>
) : (
<div className="text-sm">
-
</div>
)
},
{
title: "评查分数",
key: "score",
width: "8%",
render: (_: unknown, file: TaskDocument) => (
render: (_: unknown, record: CrossReviewDocumentWithVersion) => (
<div className="text-left">
{file.final_score ? (
<span>
{file.score_summary}
{record.score_percent != null ? (
<span className={`font-medium ${
record.score_percent >= 90 ? 'text-green-600' :
record.score_percent >= 70 ? 'text-yellow-600' :
record.score_percent >= 0 ? 'text-red-600' : 'text-gray-400'
}`}>
{record.score_percent.toFixed(1)}%
</span>
) : (
<span className="text-gray-400">-</span>
@@ -232,43 +522,65 @@ export function DocumentListModal({
)
},
{
title: "评查分数百分化",
key: "scorePercent",
title: "上传时间",
key: "uploadTime",
width: "10%",
render: (_: unknown, file: TaskDocument) => {
const value: number | null | undefined = file.score_percent as number | null | undefined;
if (value === null || value === undefined || Number.isNaN(value)) {
return <span className="text-gray-400">-</span>;
}
const numericValue = typeof value === 'string' ? Number(value) : value;
const normalized = numericValue <= 1 ? numericValue * 100 : numericValue;
const display = `${Number(normalized.toFixed(1))}%`;
return <span>{display}</span>;
render: (_: unknown, record: CrossReviewDocumentWithVersion) => {
const uploadTime = formatDate(record.upload_time).split(' ');
const date = uploadTime[0];
const time = uploadTime[1];
return (
<div>
<span className="text-sm">{date}</span>
<br />
<span className="text-xs text-gray-400">{time}</span>
</div>
);
}
},
{
title: '审核状态',
key: 'auditStatus',
width: '8%',
render: (_: unknown, file: TaskDocument) => renderAuditStatus(file)
},
{
title: "操作",
key: "operation",
width: "auto",
render: (_: unknown, file: TaskDocument) => (
<>
<Button
type="default"
size="small"
icon="ri-eye-line"
onClick={() => handleViewClickDebounced(file.document_id.toString(), file.audit_status)}
disabled={file.status !== 'Processed'}
className="mr-2"
key: "actions",
width: "13%",
render: (_: unknown, record: CrossReviewDocumentWithVersion) => (
<div className="flex flex-wrap gap-1">
{/* 查看按钮 - 与历史版本样式一致 */}
<button
type="button"
className={`text-xs px-2 py-1 h-7 mr-1 ${
record.status === 'Processed'
? 'hover:underline text-primary cursor-pointer'
: 'text-gray-400 cursor-not-allowed'
}`}
onClick={() => record.status === 'Processed' && handleViewClickDebounced(record.id.toString())}
disabled={record.status !== 'Processed'}
>
{isnavigating ? '跳转中...' : '查看'}
</Button>
</>
<i className="ri-eye-line mr-1"></i>
{isNavigating ? '跳转中...' : '查看'}
</button>
{/* 追加附件按钮 - 仅当 type_name 包含"合同"时显示 */}
{record.status === 'Processed' && taskId && record.type_name?.includes('合同') && (
<button
type="button"
className="text-xs px-2 py-1 h-7 mr-1 text-gray-500 hover:underline hover:text-gray-700"
onClick={() => handleOpenAttachmentUpload(record, record.version_number)}
>
<i className="ri-attachment-line mr-1"></i>
</button>
)}
{/* 上传模板按钮 - 仅当 type_name 包含"合同"时显示 */}
{record.status === 'Processed' && record.type_name?.includes('合同') && (
<button
type="button"
className="text-xs px-2 py-1 h-7 text-gray-500 hover:underline hover:text-gray-700"
onClick={() => handleOpenTemplateUpload(record, record.version_number)}
>
<i className="ri-file-copy-line mr-1"></i>
</button>
)}
</div>
)
}
];
@@ -281,42 +593,156 @@ export function DocumentListModal({
size="full"
className="document-list-modal"
>
<div className="px-6 py-4">
{loading ? (
// 显示loading状态
<div className="py-8">
<LoadingIndicator text="正在加载文档列表..." />
<div className="px-6 py-1">
{/* 搜索栏和统计信息 */}
<div className="mb-4 flex items-center justify-between">
{/* 左侧:文档统计 + 负责人标签 */}
<div className="flex items-center gap-4">
{/* 文档数量统计 */}
<div className="flex items-center">
<i className="ri-file-list-3-line text-primary text-lg mr-2"></i>
{loading ? (
<NumberSkeleton />
) : (
<>
<span className="text-sm text-secondary"></span>
<span className="text-base font-normal text-primary ml-1 mr-1">{total || localDocuments.length}</span>
<span className="text-sm text-secondary"></span>
</>
)}
</div>
{/* 分隔线 */}
<div className="h-5 w-px bg-gray-300"></div>
{/* 负责人标签 */}
<div className="flex items-center gap-3">
{isProposerLoading ? (
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs bg-gray-100 text-gray-500">
<i className="ri-loader-4-line animate-spin mr-1.5"></i>
...
</span>
) : isProposer ? (
<span className="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium bg-green-100 text-green-800 border border-green-200">
<i className="ri-user-star-line mr-1.5"></i>
</span>
) : (
<span className="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium bg-blue-100 text-blue-800 border border-blue-200">
<i className="ri-user-line mr-1.5"></i>
</span>
)}
{taskName && (
<span className="text-sm text-gray-500">
{taskName}
</span>
)}
</div>
</div>
) : files.length === 0 ? (
// 无数据状态
{/* 右侧:搜索框 */}
{onSearch && (
<div className="flex items-center">
<div className="relative">
<input
type="text"
placeholder="搜索文件名称或文档编号"
value={searchKeyword}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-9 pr-4 py-2 border border-gray-300 rounded-md text-sm w-64 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<i className="ri-search-line absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
{searchKeyword && (
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
onClick={() => handleSearchChange('')}
>
<i className="ri-close-line"></i>
</button>
)}
</div>
</div>
)}
</div>
{loading ? (
<TableRowSkeleton count={5} />
) : localDocuments.length === 0 ? (
<div className="text-center py-8 text-gray-500">
{searchKeyword ? '未找到匹配的文档' : '暂无文档数据'}
</div>
) : (
// 有数据时显示表格和分页
<>
<div className="mb-4 flex items-center">
<i className="ri-file-list-3-line text-primary text-lg mr-2"></i>
<span className="text-sm text-secondary"></span>
<span className="text-base font-normal text-primary ml-1 mr-1">{total || files.length}</span>
<span className="text-sm text-secondary"></span>
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead className="bg-gray-50">
<tr>
{columns.map((col) => (
<th
key={col.key}
style={{ width: col.width }}
className="px-4 py-3 text-left text-sm font-semibold text-gray-700 border-b"
>
{col.title}
</th>
))}
</tr>
</thead>
<tbody>
{localDocuments.map((doc) => (
<>
{/* 主文档行 */}
<tr
key={doc.id}
className={`border-b hover:bg-gray-50 transition-colors ${
doc.total_versions > 1 ? 'cursor-pointer' : ''
}`}
onClick={(e) => {
// 只有有历史版本的行才可以点击
if (doc.total_versions <= 1) return;
// 检查点击的是否是可交互元素
const target = e.target as HTMLElement;
const isInteractiveElement =
target.tagName === 'A' ||
target.tagName === 'BUTTON' ||
target.tagName === 'INPUT' ||
target.closest('a') ||
target.closest('button') ||
target.closest('input') ||
target.closest('.result-stats-wrapper') ||
target.closest('.result-stat-item');
if (isInteractiveElement) return;
handleToggleExpand(doc);
}}
>
{columns.map((col) => (
<td key={col.key} className="px-4 py-3 text-sm">
{col.render ? col.render(null, doc) : (doc as any)[col.key]}
</td>
))}
</tr>
{/* 历史版本行 */}
{expandedRows.has(doc.id) && doc.history_versions && doc.history_versions.length > 0 && (
doc.history_versions.map((historyDoc) => renderHistoryRow(historyDoc, doc))
)}
</>
))}
</tbody>
</table>
</div>
<Table
columns={columns}
dataSource={files}
rowKey="document_id"
emptyText="暂无文件数据"
className="files-table table-auto-height"
/>
{/* 分页组件 - 只有在提供了分页回调函数且总数大于每页大小时才显示 */}
{/* 分页组件 */}
{onPageChange && total > 0 && (
<Pagination
currentPage={currentPage}
total={total}
pageSize={pageSize}
onChange={onPageChange || (() => {})}
onChange={onPageChange}
onPageSizeChange={onPageSizeChange}
showTotal={true}
showPageSizeChanger={!!onPageSizeChange}
@@ -326,6 +752,33 @@ export function DocumentListModal({
</>
)}
</div>
{/* 追加附件模态框 */}
<AttachmentUploadModal
isOpen={showAttachmentUpload}
onClose={handleCloseModals}
documentId={selectedDocumentId}
documentName={selectedDocumentName}
documentVersion={selectedDocumentVersion}
mainFilePath={selectedDocumentPath || undefined}
onUpload={handleAttachmentUpload}
uploading={attachmentUploading}
title="追加合同附件"
supportedFormatsDesc="支持.pdf、.docx、ZIP、RAR格式。ZIP/RAR内需要保证文件格式一致"
/>
{/* 上传模板模态框 */}
<TemplateUploadModal
isOpen={showTemplateUpload}
onClose={handleCloseModals}
documentId={selectedDocumentId}
documentName={selectedDocumentName}
documentVersion={selectedDocumentVersion}
onUpload={handleTemplateUpload}
uploading={templateUploading}
title="上传合同模板"
supportedFormatsDesc="支持.pdf、.docx格式,用于与合同文档进行结构对比"
/>
</Modal>
);
}
}
@@ -33,6 +33,9 @@ import {
type SubmitOpinionRequest
} from '../../api/cross-checking/cross-file-result';
import { useFetcher, useNavigate } from '@remix-run/react';
import { CorporateInfoModal } from '../corporate-information';
import type { BusinessInfoResult, DishonestyResult } from '../corporate-information';
import { queryCompanyInfo } from '~/api/corporate-information/qichacha';
// import '../../styles/components/TooltipStyles.css';
/**
@@ -576,6 +579,80 @@ export function ReviewPointsList({
// 存放评查点ID与有效页码的映射
const [effectivePages, setEffectivePages] = useState<Record<string, number>>({});
// 企业信息模态框状态
const [corporateModalVisible, setCorporateModalVisible] = useState(false);
const [corporateCompanyName, setCorporateCompanyName] = useState('');
const [corporateBusinessInfo, setCorporateBusinessInfo] = useState<BusinessInfoResult | null>(null);
const [corporateDishonestyInfo, setCorporateDishonestyInfo] = useState<DishonestyResult | null>(null);
const [corporateLoading, setCorporateLoading] = useState(false);
const [corporateError, setCorporateError] = useState<string | null>(null);
const [corporateUpdatedAt, setCorporateUpdatedAt] = useState<string | null>(null);
/**
* 处理企业信息按钮点击
* @param companyName 企业名称(乙方名称)
* @param forceRefresh 是否强制刷新(对接企查查重新查询)
*/
const handleCorporateInfoClick = async (companyName: string, forceRefresh = false) => {
if (!companyName) {
toastService.warning('企业名称为空,无法查询');
return;
}
// 打开模态框并设置加载状态
setCorporateModalVisible(true);
setCorporateCompanyName(companyName);
setCorporateLoading(true);
setCorporateError(null);
setCorporateBusinessInfo(null);
setCorporateDishonestyInfo(null);
setCorporateUpdatedAt(null);
try {
const response = await queryCompanyInfo({ keyword: companyName, force_refresh: forceRefresh });
if (response.success && response.data) {
setCorporateBusinessInfo(response.data.enterprise);
setCorporateUpdatedAt(response.data.updated_at);
// 转换失信数据格式
if (response.data.dishonesty) {
setCorporateDishonestyInfo({
VerifyResult: response.data.dishonesty.VerifyResult,
Data: response.data.dishonesty.Data || [],
});
}
} else {
setCorporateError(response.message || '查询失败');
}
} catch (error) {
console.error('查询企业信息失败:', error);
setCorporateError(error instanceof Error ? error.message : '查询失败');
} finally {
setCorporateLoading(false);
}
};
/**
* 处理强制刷新(对接企查查重新查询)
*/
const handleCorporateForceRefresh = async () => {
if (corporateCompanyName) {
await handleCorporateInfoClick(corporateCompanyName, true);
}
};
/**
* 关闭企业信息模态框
*/
const handleCloseCorporateModal = () => {
setCorporateModalVisible(false);
setCorporateCompanyName('');
setCorporateBusinessInfo(null);
setCorporateDishonestyInfo(null);
setCorporateError(null);
setCorporateUpdatedAt(null);
};
/**
* 打开提出意见模态框
*/
@@ -2610,7 +2687,50 @@ export function ReviewPointsList({
{/* 评查点名称 pointName*/}
<div className="flex justify-between items-center mb-2">
{/* <div className='flex flex-col'> */}
<div className="review-point-title text-left text-blue-500 max-w-[75%] break-all">{reviewPoint.pointName}</div>
<div className="flex items-center gap-2 max-w-[75%]">
<div className="review-point-title text-left text-blue-500 break-all">{reviewPoint.pointName}</div>
{reviewPoint.pointName === '签署乙方详细信息校验' && (
<button
className="enterprise-info-btn"
style={{
padding: '2px 8px',
fontSize: '12px',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
gap: '4px',
flexShrink: 0,
transition: 'all 0.2s',
border: 'none',
cursor: reviewPoint.content?.['合同主体信息-乙方-名称']?.value ? 'pointer' : 'not-allowed',
backgroundColor: reviewPoint.content?.['合同主体信息-乙方-名称']?.value ? '#00684a' : '#e5e7eb',
color: reviewPoint.content?.['合同主体信息-乙方-名称']?.value ? '#ffffff' : '#9ca3af',
}}
disabled={!reviewPoint.content?.['合同主体信息-乙方-名称']?.value}
onClick={(e) => {
e.stopPropagation();
const companyNameValue = reviewPoint.content?.['合同主体信息-乙方-名称']?.value;
const companyName = typeof companyNameValue === 'string' ? companyNameValue : String(companyNameValue || '');
if (companyName) {
handleCorporateInfoClick(companyName);
}
}}
onMouseEnter={(e) => {
if (reviewPoint.content?.['合同主体信息-乙方-名称']?.value) {
e.currentTarget.style.backgroundColor = '#005a3f';
}
}}
onMouseLeave={(e) => {
if (reviewPoint.content?.['合同主体信息-乙方-名称']?.value) {
e.currentTarget.style.backgroundColor = '#00684a';
}
}}
>
<i className="ri-eye-line"></i>
</button>
)}
</div>
{/* <div className="review-point-header flex justify-between items-start">
<div className="flex-1 text-left min-w-[25%] font-medium text-[13px]">{reviewPoint.title}</div>
//评查点分组显示
@@ -2988,6 +3108,21 @@ export function ReviewPointsList({
)}
</div>
</Modal>
{/* 企业信息模态框 */}
<CorporateInfoModal
visible={corporateModalVisible}
onClose={handleCloseCorporateModal}
companyName={corporateCompanyName}
businessInfo={corporateBusinessInfo}
dishonestyInfo={corporateDishonestyInfo}
businessLoading={corporateLoading}
dishonestyLoading={corporateLoading}
businessError={corporateError}
dishonestyError={corporateError}
updatedAt={corporateUpdatedAt}
onForceRefresh={handleCorporateForceRefresh}
/>
</div>
</>
);