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 -1
View File
@@ -113,7 +113,7 @@ export async function findIsProposer(taskId: string | number, userId: number | u
);
const data = response.data;
console.log('[findIsProposer] 检查权限响应:', data);
// console.log('[findIsProposer] 检查权限响应:', data);
// 返回 can_confirm 字段,表示是否有权确认完成
// 有权限的用户:任务创建者(assigner_id) 或 主要负责人(principal_user_ids)
+468 -2
View File
@@ -75,7 +75,7 @@ export interface UserTaskApiResponse {
items: UserTaskInfo[];
}
// 任务文档接口类型定义(新增
// 任务文档接口类型定义(旧版,保留兼容
export interface TaskDocument {
document_id: number;
file_name: string;
@@ -103,6 +103,174 @@ export interface TaskDocument {
manual_count: number;
}
// ==================== 新版接口类型定义(支持版本归纳)====================
/**
* 历史版本信息
* 每个历史版本都有独立的评查统计、消息列表、分数信息
*/
export interface CrossReviewHistoryVersion {
/** 历史版本的文档ID */
id: number;
/** 版本号(从1开始,数字越小越旧) */
version_number: number;
/** 创建时间(ISO 8601格式) */
created_at: string;
/** 文件大小(字节) */
file_size: number;
/** 文件存储路径 */
path: string;
/** 文档处理状态 */
status: "Waiting" | "Cutting" | "Extractioning" | "Evaluationing" | "Failed" | "Processed";
/** 文书号/文档编号(可为null) */
document_number: string | null;
/** 文档类型ID */
type_id: number;
/** 文档类型名称 */
type_name: string;
/** 上传时间(ISO 8601格式) */
upload_time: string;
/** 任务内评查完成状态:0=未评查, 1=已评查 */
audit_status: 0 | 1;
/** 总评查点数 */
total_evaluation_points: number;
/** 通过的评查点数量 */
pass_count: number;
/** 警告的评查点数量 */
warning_count: number;
/** 错误的评查点数量 */
error_count: number;
/** 需人工审核的评查点数量 */
manual_count: number;
/** 问题总数 */
issue_count: number;
/** 警告消息列表 */
warning_messages: string[];
/** 错误消息列表 */
error_messages: string[];
/** 问题消息列表(综合:警告+错误) */
issue_messages: string[];
/** 需人工确认的消息列表 */
manual_messages: string[];
/** 最终得分 */
final_score: number;
/** 满分 */
full_score: number;
/** 得分摘要(如 "85.5/100" */
score_summary: string;
/** 得分百分比(0-100 */
score_percent: number;
}
/**
* 文档信息(含版本和评查统计)- 新版接口
*/
export interface CrossReviewDocumentWithVersion {
// ========== 基本信息 ==========
/** 当前版本的文档ID */
id: number;
/** 文档名称 */
name: string;
/** 文件存储路径 */
path: string;
/** 当前版本号(最大值,从1开始) */
version_number: number;
/** 创建时间(ISO 8601格式) */
created_at: string;
/** 文档处理状态 */
status: "Waiting" | "Cutting" | "Extractioning" | "Evaluationing" | "Failed" | "Processed";
/** 文件大小(字节) */
file_size: number;
/** 文书号/文档编号(可为null) */
document_number: string | null;
/** 文档类型ID */
type_id: number;
/** 文档类型名称 */
type_name: string;
/** 上传时间(ISO 8601格式) */
upload_time: string;
// ========== 任务内评查状态 ==========
/** 任务内评查完成状态:0=未评查, 1=已评查 */
audit_status: 0 | 1;
// ========== 评查统计 ==========
/** 总评查点数 */
total_evaluation_points: number;
/** 通过的评查点数量 */
pass_count: number;
/** 警告的评查点数量 */
warning_count: number;
/** 错误的评查点数量 */
error_count: number;
/** 需人工审核的评查点数量 */
manual_count: number;
/** 问题总数 */
issue_count: number;
// ========== 评查消息列表 ==========
/** 警告消息列表 */
warning_messages: string[];
/** 错误消息列表 */
error_messages: string[];
/** 问题消息列表(综合) */
issue_messages: string[];
/** 需人工确认的消息列表 */
manual_messages: string[];
// ========== 交叉评查特有:分数信息 ==========
/** 最终得分 */
final_score: number;
/** 满分 */
full_score: number;
/** 得分摘要(如 "85.5/100" */
score_summary: string;
/** 得分百分比(0-100 */
score_percent: number;
// ========== 版本信息 ==========
/** 总版本数 */
total_versions: number;
/** 历史版本列表(按created_at降序,不包含当前版本) */
history_versions: CrossReviewHistoryVersion[];
// ========== 前端扩展字段 ==========
/** 是否已展开历史版本(前端状态) */
isExpanded?: boolean;
}
/**
* 交叉评查任务文档列表响应
*/
export interface CrossReviewDocumentListResponse {
/** 总文档数(按版本分组后的唯一文档数) */
total: number;
/** 当前页码 */
page: number;
/** 每页数量 */
page_size: number;
/** 总页数 */
total_pages: number;
/** 文档列表 */
documents: CrossReviewDocumentWithVersion[];
}
/**
* 获取任务文档列表请求参数(支持版本归纳)
*/
export interface GetTaskDocumentsWithVersionsParams {
/** 任务ID */
taskId: number;
/** 页码(从1开始) */
page?: number;
/** 每页数量(最大100 */
pageSize?: number;
/** 模糊搜索关键字(匹配文件名称或文档编号) */
keyword?: string;
/** JWT token */
jwtToken?: string;
}
// 任务文档API响应格式(新增)
export interface TaskDocumentApiResponse {
total: number;
@@ -434,7 +602,7 @@ export async function getUserTaskDocuments(page: number = 1, pageSize: number =
}
/**
* 获取指定任务的文档列表(新增接口
* 获取指定任务的文档列表(旧版接口,保留兼容
* @param taskId 任务ID
* @param page 页码
* @param pageSize 每页大小
@@ -476,6 +644,75 @@ export async function getTaskDocuments(taskId: number, page: number = 1, pageSiz
}
}
/**
* 获取任务下文档列表(支持版本归纳)- 新版接口
*
* POST /api/v2/cross_review/tasks/{task_id}/documents
*
* 同一任务内同名且同类型的文档会被归纳为版本组,最新上传的为当前版本,其余为历史版本。
*
* @param params 请求参数
* @returns 文档列表响应(含版本信息)
*/
export async function getTaskDocumentsWithVersions(
params: GetTaskDocumentsWithVersionsParams
): Promise<ApiResponse<CrossReviewDocumentListResponse>> {
const { taskId, page = 1, pageSize = 10, keyword, jwtToken } = params;
try {
// 拼接绝对路径,去除多余斜杠
const base = API_BASE_URL.endsWith('/') ? API_BASE_URL.slice(0, -1) : API_BASE_URL;
const url = `${base}/api/v2/cross_review/tasks/${taskId}/documents`;
// 构建请求体
const queryParams: {
page: number;
page_size: number;
keyword?: string;
} = {
page,
page_size: pageSize
};
// 只有当 keyword 有值时才添加
if (keyword && keyword.trim()) {
queryParams.keyword = keyword.trim();
}
const response = await axios.get<CrossReviewDocumentListResponse>(url, {
params: queryParams,
headers: {
'Authorization': `Bearer ${jwtToken || ''}`
}
});
return {
success: true,
data: response.data
};
} catch (error) {
if (axios.isAxiosError(error)) {
// 处理特定错误码
if (error.response?.status === 403) {
return {
success: false,
error: '无权访问任务:您不是该任务的参与者',
status: 403
};
}
return {
success: false,
error: `HTTP ${error.response?.status}: ${error.response?.statusText || error.message}`,
status: error.response?.status
};
}
return {
success: false,
error: error instanceof Error ? error.message : '获取任务文档列表失败'
};
}
}
/**
* 更新文件的审核状态
@@ -564,3 +801,232 @@ export async function getCrossCheckingDocumentTypes(jwtToken?: string): Promise<
};
}
}
// ==================== 追加附件 API ====================
/**
* 追加附件响应接口
*/
export interface AppendAttachmentsResponse {
success: boolean;
result?: {
original_document_id: number;
new_document_id: number;
new_version_number: number;
task_id: number;
message: string;
background_processing: boolean;
};
error?: string;
}
/**
* 追加附件参数接口
*/
export interface AppendAttachmentsParams {
/** 任务ID */
taskId: number;
/** 原文档ID */
documentId: number;
/** 附件文件列表 */
files: File[];
/** 备注说明(可选) */
remark?: string;
/** Word附件是否使用Markdown处理(可选,默认false */
useMarkdown?: boolean;
/** JWT Token */
jwtToken?: string;
}
/**
* 为交叉评查任务文档追加附件
*
* POST /api/v2/cross_review/tasks/{task_id}/documents/{document_id}/append_attachments
*
* 追加附件后创建新文档(新版本),原文档保留。
* 新文档自动关联到当前任务,audit_status = 0(需重新评查)
*
* @param params 追加附件参数
* @returns 追加结果
*/
export async function appendTaskDocumentAttachments(
params: AppendAttachmentsParams
): Promise<ApiResponse<AppendAttachmentsResponse>> {
const { taskId, documentId, files, remark, useMarkdown = false, jwtToken } = params;
try {
if (!taskId || taskId <= 0) {
return { success: false, error: '任务ID无效' };
}
if (!documentId || documentId <= 0) {
return { success: false, error: '文档ID无效' };
}
if (!files || files.length === 0) {
return { success: false, error: '请选择附件文件' };
}
// 构建 FormData
const formData = new FormData();
files.forEach(file => {
formData.append('files', file);
});
if (remark) {
formData.append('remark', remark);
}
formData.append('use_markdown', useMarkdown.toString());
// 构建请求 URL
const base = API_BASE_URL.endsWith('/') ? API_BASE_URL.slice(0, -1) : API_BASE_URL;
const url = `${base}/api/v2/cross_review/tasks/${taskId}/documents/${documentId}/append_attachments`;
const response = await axios.post<AppendAttachmentsResponse>(url, formData, {
headers: {
'Content-Type': 'multipart/form-data',
'Authorization': `Bearer ${jwtToken || ''}`
}
});
return {
success: true,
data: response.data
};
} catch (error) {
if (axios.isAxiosError(error)) {
if (error.response?.status === 403) {
return {
success: false,
error: '无权追加附件:您不是任务创建者或负责人',
status: 403
};
}
if (error.response?.status === 400) {
return {
success: false,
error: error.response.data?.error || '请求参数错误',
status: 400
};
}
return {
success: false,
error: `HTTP ${error.response?.status}: ${error.response?.statusText || error.message}`,
status: error.response?.status
};
}
return {
success: false,
error: error instanceof Error ? error.message : '追加附件失败'
};
}
}
// ==================== 上传模板 API ====================
/**
* 上传模板响应接口(文档级别)
*/
export interface UploadDocumentTemplateResponse {
success: boolean;
result?: {
document_id: number;
comparison_id: number;
template_name: string;
template_path: string;
status: string;
message: string;
};
error?: string;
}
/**
* 上传模板参数接口(文档级别)
*/
export interface UploadDocumentTemplateParams {
/** 文档ID */
documentId: number;
/** 模板文件 */
file: File;
/** 对比记录ID(可选,用于更新已有模板) */
comparisonId?: number;
/** JWT Token */
jwtToken?: string;
}
/**
* 为交叉评查任务中的文档上传模板(文档级别)
*
* 复用现有的 /upload_contract_template 接口,与 files-upload.ts 中的 uploadContractTemplate 保持一致
*
* @param params 上传模板参数
* @returns 上传结果
*/
export async function uploadCrossReviewDocumentTemplate(
params: UploadDocumentTemplateParams
): Promise<ApiResponse<UploadDocumentTemplateResponse>> {
const { documentId, file, comparisonId, jwtToken } = params;
try {
if (!documentId || documentId <= 0) {
return { success: false, error: '文档ID无效' };
}
if (!file) {
return { success: false, error: '请选择模板文件' };
}
// 构建 FormData,与 files-upload.ts 中的 uploadContractTemplate 保持一致
const formData = new FormData();
formData.append('file', file);
// upload_info 作为 JSON 字符串
const uploadInfo: { document_id: number; comparison_id?: number } = {
document_id: documentId
};
if (comparisonId) {
uploadInfo.comparison_id = comparisonId;
}
formData.append('upload_info', JSON.stringify(uploadInfo));
// 使用与 files-upload.ts 相同的上传接口
const { UPLOAD_URL } = await import('~/config/api-config');
const url = `${UPLOAD_URL}/upload_contract_template`;
const response = await axios.post<UploadDocumentTemplateResponse>(url, formData, {
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${jwtToken || ''}`
}
});
return {
success: true,
data: response.data
};
} catch (error) {
if (axios.isAxiosError(error)) {
if (error.response?.status === 403) {
return {
success: false,
error: '无权上传模板',
status: 403
};
}
if (error.response?.status === 400) {
return {
success: false,
error: error.response.data?.error || '请求参数错误',
status: 400
};
}
return {
success: false,
error: `HTTP ${error.response?.status}: ${error.response?.statusText || error.message}`,
status: error.response?.status
};
}
return {
success: false,
error: error instanceof Error ? error.message : '上传模板失败'
};
}
}
+58 -1
View File
@@ -1,7 +1,64 @@
import { postgrestGet, type PostgrestParams } from '../postgrest-client';
import dayjs from 'dayjs';
import { UPLOAD_URL } from '../../config/api-config';
import { UPLOAD_URL, API_BASE_URL } from '../../config/api-config';
import axios from 'axios';
/**
* 检查文档名称是否重复
* @param name 文档名称
* @param typeId 文档类型ID
* @returns 重复检查结果
*/
export async function checkDocumentDuplicate(
name: string,
typeId: number
): Promise<{ is_duplicate: boolean; count: number }> {
try {
// 获取 token
let token: string | null = null;
if (typeof window !== 'undefined') {
token = localStorage.getItem('access_token');
}
const headers: Record<string, string> = {
'Accept': 'application/json'
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await axios.get(
`${API_BASE_URL}/api/v2/documents/check-duplicate`,
{
params: { name, type_id: typeId },
headers
}
);
// 解析响应数据
const data = response.data;
if (data && typeof data === 'object') {
// 处理标准响应格式 { code, msg, data }
if ('data' in data && data.data) {
return {
is_duplicate: data.data.is_duplicate ?? false,
count: data.data.count ?? 0
};
}
// 直接返回数据格式
return {
is_duplicate: data.is_duplicate ?? false,
count: data.count ?? 0
};
}
return { is_duplicate: false, count: 0 };
} catch (error) {
console.error('【文档重名检查】检查失败:', error);
// 检查失败时默认允许上传
return { is_duplicate: false, count: 0 };
}
}
// import { API_BASE_URL } from '../client';
/**
@@ -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 statusKey = auditStatus.toString();
const statusInfo = auditStatusMapping[statusKey] || auditStatusMapping["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 = 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)}
</div>
</div>
</div>
)
},
{
title: "文件类型",
key: "fileType",
width: "8%",
render: (_: unknown, file: TaskDocument) => (
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="other"
typeName={file.file_type_name}
text={file.file_type_name}
type={record.type_id.toString()}
typeName={record.type_name}
text={record.type_name}
size="sm"
showIcon={false}
colorMode="light"
/>
)
},
{
title: "上传时间",
key: "uploadTime",
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 */}
{/* 版本徽章 */}
{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>
);
}
},
{
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",
title: "文件大小",
key: "size",
width: "8%",
render: (_: unknown, file: TaskDocument) => (
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: "scorePercent",
width: "8%",
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>;
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>
);
}
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>;
}
},
{
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">
<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 ? (
// 显示loading状态
<div className="py-8">
<LoadingIndicator text="正在加载文档列表..." />
<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>
) : files.length === 0 ? (
// 无数据状态
{/* 分隔线 */}
<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>
{/* 右侧:搜索框 */}
{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>
</>
);
+323
View File
@@ -0,0 +1,323 @@
import { useState } from "react";
import { messageService } from "./MessageModal";
// 格式化文件大小
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 interface AttachmentUploadModalProps {
/** 是否显示 */
isOpen: boolean;
/** 关闭回调 */
onClose: () => void;
/** 目标文档ID */
documentId: number | null;
/** 目标文档名称 */
documentName: string | null;
/** 目标文档版本号(可选) */
documentVersion?: number | null;
/** 主文件路径(用于判断是否为docx) */
mainFilePath?: string;
/** 上传回调 */
onUpload: (files: File[], mergeMode: 'overwrite' | 'new', remark: string) => Promise<void>;
/** 是否正在上传 */
uploading?: boolean;
/** 标题(可选,默认"追加合同附件" */
title?: string;
/** 支持的文件格式描述(可选) */
supportedFormatsDesc?: string;
/** 是否显示合并模式选择(可选,默认false,仅显示新建模式) */
showMergeMode?: boolean;
}
export function AttachmentUploadModal({
isOpen,
onClose,
documentId,
documentName,
documentVersion,
mainFilePath,
onUpload,
uploading = false,
title = "追加合同附件",
supportedFormatsDesc = "支持.pdf、.docx、ZIP、RAR格式。ZIP/RAR内需要保证文件格式一致,否则报错",
showMergeMode = false
}: AttachmentUploadModalProps) {
// 附件文件列表
const [attachmentFiles, setAttachmentFiles] = useState<File[]>([]);
// 合并模式
const [attachmentMergeMode, setAttachmentMergeMode] = useState<'overwrite' | 'new'>('new');
// 备注
const [attachmentRemark, setAttachmentRemark] = useState<string>("");
// 拖拽状态
const [isDragging, setIsDragging] = useState<boolean>(false);
// 重置状态
const resetState = () => {
setAttachmentFiles([]);
setAttachmentRemark("");
setIsDragging(false);
};
// 关闭处理
const handleClose = () => {
resetState();
onClose();
};
// 处理文件选择
const handleFilesSelected = (files: FileList) => {
try {
if (files.length > 0) {
// 检查主文件类型
const isMainFileDocx = mainFilePath?.toLowerCase().endsWith('.docx');
// 验证文件类型
const validFiles: File[] = [];
let hasInvalidFiles = false;
let hasPdfForDocx = false;
Array.from(files).forEach(file => {
const fileName = file.name.toLowerCase();
const isPdf = file.type === 'application/pdf' || fileName.endsWith('.pdf');
const isValidType =
isPdf ||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || fileName.endsWith('.docx') ||
file.type === 'application/zip' || fileName.endsWith('.zip') ||
file.type === 'application/x-rar-compressed' || fileName.endsWith('.rar');
// 如果主文件是docx,不允许上传pdf附件
if (isMainFileDocx && isPdf) {
hasPdfForDocx = true;
return;
}
if (isValidType) {
validFiles.push(file);
} else {
hasInvalidFiles = true;
}
});
if (hasPdfForDocx) {
messageService.error('主文件为DOCX格式时,附件不可以是PDF格式', {
title: '文件类型限制',
confirmText: '确定',
cancelText: '',
});
} else if (hasInvalidFiles) {
messageService.error('只支持PDF、Word、ZIP、RAR格式的文件', {
title: '文件类型错误',
confirmText: '确定',
cancelText: '',
});
}
if (validFiles.length > 0) {
setAttachmentFiles(validFiles);
}
}
} catch (error) {
console.error('处理文件选择时发生错误:', error);
}
};
// 处理上传
const handleUpload = async () => {
if (!documentId || attachmentFiles.length === 0) {
return;
}
await onUpload(attachmentFiles, attachmentMergeMode, attachmentRemark);
resetState();
};
// 拖拽事件处理
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = e.dataTransfer.files;
if (files && files.length > 0) {
handleFilesSelected(files);
}
};
if (!isOpen) return null;
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]"
onClick={handleClose}
>
<div
className="bg-white rounded-lg p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">{title}</h3>
<button
onClick={handleClose}
className="text-gray-400 hover:text-gray-600"
>
<i className="ri-close-line text-xl"></i>
</button>
</div>
<div className="space-y-4">
{/* 文档信息 */}
<div className="bg-gray-50 p-3 rounded">
<p className="text-sm text-gray-600">
: <span className="font-medium">{documentName}</span>
{documentVersion && (
<span className="ml-2 text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
v{documentVersion}
</span>
)}
</p>
<p className="text-xs text-gray-500 mt-1">
<i className="ri-information-line mr-1"></i>
{supportedFormatsDesc}
</p>
</div>
{/* 文件上传区域 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<span className="text-red-500">*</span>
</label>
<div
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
isDragging
? 'border-primary bg-primary-light'
: 'border-gray-300 hover:border-gray-400'
}`}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
type="file"
multiple
accept=".pdf,.doc,.docx,.zip,.rar"
onChange={(e) => e.target.files && handleFilesSelected(e.target.files)}
className="hidden"
id="attachment-file-input-modal"
/>
<label htmlFor="attachment-file-input-modal" className="cursor-pointer">
<i className={`ri-attachment-line text-3xl mb-2 block ${isDragging ? 'text-primary' : 'text-gray-400'}`}></i>
<p className={`text-sm ${isDragging ? 'text-primary font-medium' : 'text-gray-600'}`}>
{isDragging ? '松开鼠标上传文件' : '点击选择文件或拖拽文件到此处'}
</p>
<p className="text-xs text-gray-500 mt-1">.pdf.docx.zip.rar格式</p>
</label>
</div>
{attachmentFiles.length > 0 && (
<div className="mt-2">
<p className="text-sm text-green-600 mb-2">
<i className="ri-checkbox-circle-line"></i> {attachmentFiles.length}
</p>
<div className="space-y-1 max-h-32 overflow-y-auto">
{attachmentFiles.map((file, index) => (
<div key={index} className="text-xs text-gray-600 bg-gray-50 p-2 rounded">
<i className="ri-file-line mr-1"></i>
{file.name} ({formatFileSize(file.size)})
</div>
))}
</div>
</div>
)}
</div>
{/* 合并模式选择(可选) */}
{showMergeMode && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="mergeMode"
value="overwrite"
checked={attachmentMergeMode === 'overwrite'}
onChange={(e) => setAttachmentMergeMode(e.target.value as 'overwrite' | 'new')}
className="mr-2"
/>
<span className="text-sm"></span>
</label>
<label className="flex items-center">
<input
type="radio"
name="mergeMode"
value="new"
checked={attachmentMergeMode === 'new'}
onChange={(e) => setAttachmentMergeMode(e.target.value as 'overwrite' | 'new')}
className="mr-2"
/>
<span className="text-sm"></span>
</label>
</div>
</div>
)}
{/* 备注 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<textarea
value={attachmentRemark}
onChange={(e) => setAttachmentRemark(e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md text-sm"
rows={3}
placeholder="请输入备注信息..."
/>
</div>
{/* 操作按钮 */}
<div className="flex justify-end gap-3 pt-4 border-t">
<button
className="px-4 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-50"
onClick={handleClose}
disabled={uploading}
>
</button>
<button
className="px-4 py-2 text-sm bg-primary text-white rounded-md hover:bg-primary-dark disabled:opacity-50"
onClick={handleUpload}
disabled={attachmentFiles.length === 0 || uploading}
>
{uploading ? '上传中...' : '开始追加'}
</button>
</div>
</div>
</div>
</div>
);
}
+228
View File
@@ -0,0 +1,228 @@
import { useState } from "react";
import { messageService } from "./MessageModal";
// 格式化文件大小
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 interface TemplateUploadModalProps {
/** 是否显示 */
isOpen: boolean;
/** 关闭回调 */
onClose: () => void;
/** 目标文档ID */
documentId: number | null;
/** 目标文档名称 */
documentName: string | null;
/** 目标文档版本号(可选) */
documentVersion?: number | null;
/** 上传回调 */
onUpload: (file: File) => Promise<void>;
/** 是否正在上传 */
uploading?: boolean;
/** 标题(可选,默认"上传合同模板" */
title?: string;
/** 支持的文件格式描述(可选) */
supportedFormatsDesc?: string;
}
export function TemplateUploadModal({
isOpen,
onClose,
documentId,
documentName,
documentVersion,
onUpload,
uploading = false,
title = "上传合同模板",
supportedFormatsDesc = "支持.pdf、.docx格式,用于与合同文档进行结构对比"
}: TemplateUploadModalProps) {
// 模板文件
const [templateFile, setTemplateFile] = useState<File | null>(null);
// 拖拽状态
const [isDragging, setIsDragging] = useState<boolean>(false);
// 重置状态
const resetState = () => {
setTemplateFile(null);
setIsDragging(false);
};
// 关闭处理
const handleClose = () => {
resetState();
onClose();
};
// 处理文件选择
const handleFileSelected = (files: FileList) => {
try {
if (files.length > 0) {
const file = files[0];
// 验证文件类型
const fileName = file.name.toLowerCase();
const isValidType =
file.type === 'application/pdf' || fileName.endsWith('.pdf') ||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || fileName.endsWith('.docx');
if (isValidType) {
setTemplateFile(file);
} else {
messageService.error('只支持.pdf、.docx格式的文件', {
title: '文件类型错误',
confirmText: '确定',
cancelText: '',
});
}
}
} catch (error) {
console.error('处理文件选择时发生错误:', error);
}
};
// 处理上传
const handleUpload = async () => {
if (!documentId || !templateFile) {
return;
}
await onUpload(templateFile);
resetState();
};
// 拖拽事件处理
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = e.dataTransfer.files;
if (files && files.length > 0) {
handleFileSelected(files);
}
};
if (!isOpen) return null;
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]"
onClick={handleClose}
>
<div
className="bg-white rounded-lg p-6 w-full max-w-xl mx-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">{title}</h3>
<button
onClick={handleClose}
className="text-gray-400 hover:text-gray-600"
>
<i className="ri-close-line text-xl"></i>
</button>
</div>
<div className="space-y-4">
{/* 文档信息 */}
<div className="bg-gray-50 p-3 rounded">
<p className="text-sm text-gray-600">
: <span className="font-medium">{documentName}</span>
{documentVersion && (
<span className="ml-2 text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
v{documentVersion}
</span>
)}
</p>
<p className="text-xs text-gray-500 mt-1">
{supportedFormatsDesc}
</p>
</div>
{/* 文件上传区域 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<span className="text-red-500">*</span>
</label>
<div
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
isDragging
? 'border-primary bg-primary-light'
: 'border-gray-300 hover:border-gray-400'
}`}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
type="file"
accept=".pdf,.docx"
onChange={(e) => e.target.files && handleFileSelected(e.target.files)}
className="hidden"
id="template-file-input-modal"
/>
<label htmlFor="template-file-input-modal" className="cursor-pointer">
<i className={`ri-file-copy-line text-3xl mb-2 block ${isDragging ? 'text-primary' : 'text-gray-400'}`}></i>
<p className={`text-sm ${isDragging ? 'text-primary font-medium' : 'text-gray-600'}`}>
{isDragging ? '松开鼠标上传文件' : '点击选择文件或拖拽文件到此处'}
</p>
<p className="text-xs text-gray-500 mt-1">.pdf.docx格式</p>
</label>
</div>
{templateFile && (
<div className="mt-2">
<p className="text-sm text-green-600 mb-2">
<i className="ri-checkbox-circle-line"></i>
</p>
<div className="text-xs text-gray-600 bg-gray-50 p-2 rounded">
<i className="ri-file-line mr-1"></i>
{templateFile.name} ({formatFileSize(templateFile.size)})
</div>
</div>
)}
</div>
{/* 操作按钮 */}
<div className="flex justify-end gap-3 pt-4 border-t">
<button
className="px-4 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-50"
onClick={handleClose}
disabled={uploading}
>
</button>
<button
className="px-4 py-2 text-sm bg-primary text-white rounded-md hover:bg-primary-dark disabled:opacity-50"
onClick={handleUpload}
disabled={!templateFile || uploading}
>
{uploading ? '上传中...' : '开始上传'}
</button>
</div>
</div>
</div>
</div>
);
}
+80 -59
View File
@@ -18,14 +18,18 @@ import {
deleteCrossCheckingTask,
getCrossCheckingTaskDetail,
getCrossCheckingDocumentTypes,
getTaskDocumentsWithVersions,
type CrossCheckingTask,
type TaskDocument,
type CrossReviewDocumentWithVersion,
type CrossReviewDocumentListResponse,
type TaskListParams,
type DocumentType,
CrossCheckingTaskStatus,
CrossCheckingTaskType,
CrossCheckingDocType
} from '~/api/cross-checking/cross-files';
import { findIsProposer } from '~/api/cross-checking/cross-file-result';
export const links = () => [
{ rel: "stylesheet", href: crossCheckingStyles }
@@ -258,23 +262,28 @@ export default function CrossCheckingIndex() {
// 状态管理
const [isDeleting, setIsDeleting] = useState(false);
const [hasAutoOpened, setHasAutoOpened] = useState(false); // 标记是否已自动打开模态框
const [isProposer, setIsProposer] = useState<boolean>(false); // 是否是负责人
const [isProposerLoading, setIsProposerLoading] = useState<boolean>(false); // 负责人状态加载中
const [modalState, setModalState] = useState<{
isOpen: boolean;
title: string;
files: TaskDocument[];
documents: CrossReviewDocumentWithVersion[];
loading: boolean;
// 分页相关状态
currentPage: number;
pageSize: number;
total: number;
// 搜索关键词
keyword: string;
}>({
isOpen: false,
title: '',
files: [],
documents: [],
loading: false,
currentPage: 1,
pageSize: 10,
total: 0
total: 0,
keyword: ''
});
// 客户端调式日志
@@ -294,16 +303,28 @@ export default function CrossCheckingIndex() {
// 存储任务信息用于分页
setCurrentTaskInfo({ taskId, taskName });
// 打开模态框
// 重置负责人状态
setIsProposer(false);
setIsProposerLoading(true);
// 打开模态框,同时设置标题
setModalState(prev => ({
...prev,
isOpen: true,
title: `${taskName} - 文档列表`,
currentPage: 1,
pageSize: 10
}));
// 加载第一页数据
await loadModalData(taskId, 1, 10);
// 并行加载:文档列表和负责人状态
const [, isProposerResult] = await Promise.all([
loadModalData(taskId, 1, 10, undefined, taskName),
findIsProposer(taskId, undefined, frontendJWT)
]);
// 设置负责人状态
setIsProposer(isProposerResult);
setIsProposerLoading(false);
};
// 关闭模态框
@@ -311,13 +332,17 @@ export default function CrossCheckingIndex() {
setModalState({
isOpen: false,
title: '',
files: [],
documents: [],
loading: false,
currentPage: 1,
pageSize: 10,
total: 0
total: 0,
keyword: ''
});
setCurrentTaskInfo(null);
// 重置负责人状态
setIsProposer(false);
setIsProposerLoading(false);
};
// 处理文档查看 - 导航到评查详情页
@@ -337,22 +362,43 @@ export default function CrossCheckingIndex() {
taskName: string;
} | null>(null);
// 加载分页数据
const loadModalData = async (taskId: number, page: number = 1, pageSize: number = 10) => {
// 加载分页数据(使用新版接口,支持版本归纳)
const loadModalData = async (taskId: number, page: number = 1, pageSize: number = 10, keyword?: string, taskName?: string) => {
try {
setModalState(prev => ({
...prev,
loading: true
loading: true,
currentPage: page,
pageSize: pageSize
}));
// 使用 fetcher 调用 action 来获取任务详情
const formData = new FormData();
formData.append('_action', 'getTaskDetail');
formData.append('taskId', taskId.toString());
formData.append('page', page.toString());
formData.append('pageSize', pageSize.toString());
// 直接调用新版 API(支持版本归纳)
const response = await getTaskDocumentsWithVersions({
taskId,
page,
pageSize,
keyword,
jwtToken: frontendJWT
});
fetcher.submit(formData, { method: "POST" });
if (!response.success || !response.data) {
throw new Error(response.error || '获取任务文档列表失败');
}
const { documents, total, page: returnedPage, page_size: returnedPageSize, total_pages } = response.data;
// 使用传入的 taskName 或 currentTaskInfo 中的 taskName
const displayTaskName = taskName || currentTaskInfo?.taskName || '';
setModalState(prev => ({
...prev,
loading: false,
title: displayTaskName ? `${displayTaskName} - 文档列表` : prev.title,
documents: documents || [],
total: total || 0,
currentPage: returnedPage || page,
pageSize: returnedPageSize || pageSize
}));
} catch (error) {
console.error('获取任务文档列表失败:', error);
@@ -368,14 +414,22 @@ export default function CrossCheckingIndex() {
// 处理模态框分页变化
const handleModalPageChange = (page: number) => {
if (currentTaskInfo) {
loadModalData(currentTaskInfo.taskId, page, modalState.pageSize);
loadModalData(currentTaskInfo.taskId, page, modalState.pageSize, modalState.keyword);
}
};
// 处理模态框每页大小变化
const handleModalPageSizeChange = (size: number) => {
if (currentTaskInfo) {
loadModalData(currentTaskInfo.taskId, 1, size);
loadModalData(currentTaskInfo.taskId, 1, size, modalState.keyword);
}
};
// 处理模态框搜索
const handleModalSearch = (keyword: string) => {
if (currentTaskInfo) {
setModalState(prev => ({ ...prev, keyword }));
loadModalData(currentTaskInfo.taskId, 1, modalState.pageSize, keyword);
}
};
@@ -561,44 +615,6 @@ export default function CrossCheckingIndex() {
}
}, [fetcher.data, fetcher.state, isDeleting]);
// 监听fetcher状态变化 - 获取任务详情
useEffect(() => {
if (fetcher.data && fetcher.state === 'idle' && !isDeleting && modalState.loading) {
const data = fetcher.data as {
success?: boolean;
data?: {
files: TaskDocument[];
total: number;
currentPage: number;
pageSize: number;
};
error?: string;
};
if (data.success && data.data) {
const { files, total, currentPage, pageSize: returnedPageSize } = data.data;
setModalState(prev => ({
...prev,
loading: false,
title: `${currentTaskInfo?.taskName || ''} - 文档列表`,
files: files || [],
total: total || 0,
currentPage: currentPage || prev.currentPage,
pageSize: returnedPageSize || prev.pageSize
}));
} else {
console.error('获取任务文档列表失败:', data.error);
toastService.error(`获取任务文档列表失败: ${data.error || '未知错误'}`);
setModalState(prev => ({
...prev,
loading: false
}));
}
}
}, [fetcher.data, fetcher.state, isDeleting, modalState.loading, currentTaskInfo?.taskId]);
// 定义表格列配置
const columns = [
{
@@ -849,7 +865,7 @@ export default function CrossCheckingIndex() {
isOpen={modalState.isOpen}
onClose={handleCloseModal}
title={modalState.title}
files={modalState.files}
documents={modalState.documents}
onViewFile={handleViewFile}
loading={modalState.loading}
currentPage={modalState.currentPage}
@@ -857,7 +873,12 @@ export default function CrossCheckingIndex() {
total={modalState.total}
onPageChange={handleModalPageChange}
onPageSizeChange={handleModalPageSizeChange}
onSearch={handleModalSearch}
taskId={currentTaskInfo?.taskId}
taskName={currentTaskInfo?.taskName}
frontendJWT={frontendJWT}
isProposer={isProposer}
isProposerLoading={isProposerLoading}
/>
</div>
);
+54 -6
View File
@@ -38,6 +38,9 @@ import {
ReviewPointsList
} from "~/components/cross-checking";
// 导入文档对比组件
import { ComparePreview } from "~/components/reviews/previewComponents/ComparePreview";
// 从ReviewPointsList组件中导入ReviewPoint类型和CharPosition类型
import { type ReviewPoint, type CharPosition } from '~/components/cross-checking';
import { messageService } from "~/components/ui/MessageModal";
@@ -232,12 +235,13 @@ export async function loader({ request }: LoaderFunctionArgs) {
// 获取评查点数据,传递request对象 旧获取评查点结果的方法
// const reviewData = await getReviewPoints(id, request);
// 获取当前登录用户是否是发起
// 获取当前登录用户是否是负责
const isProposer = await findIsProposer(taskId, userInfo?.user_id, frontendJWT);
// console.log("documentData-------",JSON.stringify(documentData.data,null,2));
// console.log("reviewData-------",JSON.stringify('data' in reviewData ? reviewData.data : '',null,2));
// console.log("reviewData-------",JSON.stringify(reviewData,null,2));
// console.log("reviewData-------",JSON.stringify(reviewData.comparison_document));
if ('error' in reviewData && reviewData.error) {
console.error("获取评查点数据错误:", reviewData.error);
return Response.json({ result: false, message: reviewData.error });
@@ -331,9 +335,16 @@ export default function CrossCheckingResult() {
const navigate = useNavigate();
const loaderData = useLoaderData<typeof loader>();
const { document, reviewPoints, statistics, reviewInfo, scoring_proposals, jwtToken, userInfo, isProposer, taskId, taskName } = loaderData;
const { document, reviewPoints, statistics, reviewInfo, comparison_document, scoring_proposals, jwtToken, userInfo, isProposer, taskId, taskName } = loaderData;
const [isLoading, setIsLoading] = useState(false); // 已经通过loader加载了数据,不需要再显示加载状态
// 视图切换状态:'review' = 评查结果视图, 'compare' = 结构比对视图
const [viewMode, setViewMode] = useState<'review' | 'compare'>('review');
// 判断是否有模板可以进行结构比对
const hasTemplateForCompare = Boolean(comparison_document?.template_contract_path?.trim());
// 权限控制
const { hasPermission } = usePermission();
const canCompleteDocument = hasPermission('cross_review:document:complete'); // 完成评查按钮
@@ -357,9 +368,9 @@ export default function CrossCheckingResult() {
const isProcessingRef = useRef(false);
// 添加组件挂载/卸载日志
useEffect(() => {
console.log('[组件] CrossCheckingResult', isProposer);
}, [isProposer]);
// useEffect(() => {
// console.log('[组件] CrossCheckingResult', isProposer);
// }, [isProposer]);
// 同步外部scoring_proposals到本地状态
useEffect(() => {
@@ -743,6 +754,27 @@ export default function CrossCheckingResult() {
</div>
</div>
{/* 结构比对/查看评查结果按钮 - 仅当文档类型包含"合同"且有模板时显示 */}
{hasTemplateForCompare && (
<button
type="button"
onClick={() => setViewMode(viewMode === 'review' ? 'compare' : 'review')}
className="inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 mr-2"
>
{viewMode === 'review' ? (
<>
<i className="ri-file-copy-2-line mr-1.5"></i>
</>
) : (
<>
<i className="ri-file-list-3-line mr-1.5"></i>
</>
)}
</button>
)}
{/* 完成评查按钮 - 需要 isProposer 且拥有 cross_review:document:complete 权限 */}
{isProposer && canCompleteDocument && (
<button
@@ -781,7 +813,9 @@ export default function CrossCheckingResult() {
onConfirmResults={handleConfirmResults}
/> */}
{/* 交叉评查结果内容 */}
{/* 根据视图模式切换内容 */}
{viewMode === 'review' ? (
/* 评查结果视图 */
<div className="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:space-x-4 lg:justify-between">
{/* 左侧:文件预览 */}
<div className="w-full lg:w-[62%]">
@@ -817,6 +851,20 @@ export default function CrossCheckingResult() {
/>
</div>
</div>
) : (
/* 结构比对视图 */
<div className="w-full" style={{
height: 'calc(100vh - 120px)',
minHeight: '600px',
display: 'flex',
flexDirection: 'column'
}}>
<ComparePreview
doc1Path={document?.path || ''}
doc2Path={comparison_document?.template_contract_path || ''}
/>
</div>
)}
</>
)}
</div>
+44 -11
View File
@@ -18,6 +18,7 @@ import {
uploadDocumentToServer,
appendContractAttachments,
uploadContractTemplate,
checkDocumentDuplicate,
type Document,
type DocumentType,
type FileUploadResponse,
@@ -656,9 +657,12 @@ export default function FilesUpload() {
}
if (validFiles.length > 0) {
setCurrentFiles(validFiles);
if (fileType) {
startUpload(validFiles);
// 通过 checkAndPrepareUpload 进行重名检查后再上传
checkAndPrepareUpload(validFiles, [], []);
} else {
// 如果没有选择文件类型,先保存文件等待用户选择
setCurrentFiles(validFiles);
}
}
}
@@ -694,7 +698,8 @@ export default function FilesUpload() {
// 如果已经有选中的文件,且选择了文件类型,且不是合同类型,则开始上传
if (currentFiles.length > 0 && !isContract) {
// console.log('【调试-handleFileTypeChange】自动开始上传非合同类型文件');
startUpload(currentFiles);
// 通过 checkAndPrepareUpload 进行重名检查后再上传
checkAndPrepareUpload(currentFiles, [], []);
} else if (currentFiles.length > 0 && isContract) {
// console.log('【调试-handleFileTypeChange】合同类型需要手动点击开始上传按钮');
// 合同类型不自动上传,需要用户先上传主文件和附件,然后点击开始上传按钮
@@ -1063,6 +1068,20 @@ export default function FilesUpload() {
// 只允许一个主文件
const mainFile = mainFiles[0];
// 检查文档名称是否重复
const duplicateResult = await checkDocumentDuplicate(mainFile.name, Number(fileType));
if (duplicateResult.is_duplicate) {
const confirmed = window.confirm(
'存在同名文件,会归纳到历史版本中。如需纳入历史版本请点击确定,否则点击取消并重新命名文档后再上传。'
);
if (!confirmed) {
// 用户取消时,清空合同主文件输入框以便重新选择文件
contractMainFileRef.current?.resetFileInput();
setContractMainFiles([]);
return;
}
}
// 为进度条提供文件集合与阶段
const filesForProgress = [mainFile, ...attachmentFiles, ...templateFiles];
setCurrentFiles(filesForProgress);
@@ -1198,14 +1217,14 @@ export default function FilesUpload() {
};
// 检查并准备上传
const checkAndPrepareUpload = (mainFiles: File[], attachmentFiles: File[], templateFiles: File[] = []) => {
const checkAndPrepareUpload = async (mainFiles: File[], attachmentFiles: File[], templateFiles: File[] = []) => {
try {
console.log('【调试-checkAndPrepareUpload】开始检查并准备上传文件', {
mainFilesCount: mainFiles.length,
attachmentFilesCount: attachmentFiles.length,
templateFilesCount: templateFiles.length,
fileType
});
// console.log('【调试-checkAndPrepareUpload】开始检查并准备上传文件', {
// mainFilesCount: mainFiles.length,
// attachmentFilesCount: attachmentFiles.length,
// templateFilesCount: templateFiles.length,
// fileType
// });
// 检查组件是否已卸载
if (!isMountedRef.current) {
@@ -1252,11 +1271,25 @@ export default function FilesUpload() {
return;
}
// 检查主文件名称是否重复(在任何状态变化之前进行检查)
const mainFile = allFiles[0];
const duplicateResult = await checkDocumentDuplicate(mainFile.name, Number(fileType));
if (duplicateResult.is_duplicate) {
const confirmed = window.confirm(
'存在同名文件,会归纳到历史版本中。如需纳入历史版本请点击确定,否则点击取消并重新命名文档后再上传。'
);
if (!confirmed) {
// 用户取消时,清空文件输入框以便重新选择文件
uploadAreaRef.current?.resetFileInput();
return;
}
}
// 这里的currentFiles的长度是上传进度条是否显示的关键
setCurrentFiles(allFiles);
// 将准备上传的操作移到这里,暂时不执行
console.log('【调试-checkAndPrepareUpload】准备上传', allFiles.length, '个文件');
// console.log('【调试-checkAndPrepareUpload】准备上传', allFiles.length, '个文件');
if (fileType) {
try {
File diff suppressed because it is too large Load Diff