Files
leaudit-platform-frontend/app/routes/files.upload.tsx
T

3037 lines
116 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from "react";
import { MetaFunction, ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useLoaderData, useNavigate, useRevalidator } from "@remix-run/react";
import { Card } from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import { Table } from "~/components/ui/Table";
import { UploadArea, UploadAreaRef } from "~/components/ui/UploadArea";
import { FileProgress} from "~/components/ui/FileProgress";
import { ProcessingSteps, Step } from "~/components/ui/ProcessingSteps";
import uploadStyles from "~/styles/pages/files_upload.css?url";
import { messageService } from "~/components/ui/MessageModal";
import { toastService } from "~/components/ui/Toast";
import {
getTodayDocuments,
getDocumentTypes,
getDocumentsStatus,
uploadFileToBinary,
uploadDocumentToServer,
appendContractAttachments,
uploadContractTemplate,
checkDocumentDuplicate,
type Document,
type DocumentType,
type FileUploadResponse,
DocumentStatus
} from "~/api/files/files-upload";
import { updateDocumentAuditStatus } from "~/api/evaluation_points/rules-files";
import { links as fileTypeTagLinks } from "~/components/ui/FileTypeTag";
import { getQueueStatus, type QueueStatus } from "~/api/queue";
import { CONTRACT_TYPES, DEFAULT_CONTRACT_TYPE } from "~/constants/contractTypes";
export function links() {
return [
{ rel: "stylesheet", href: uploadStyles },
...fileTypeTagLinks()
];
}
// 面包屑导航
export const handle = {
breadcrumb: "文档上传",
previousRoute: {
title: "文档列表",
to: "/documents/list"
}
};
export const meta: MetaFunction = () => {
return [
{ title: "待审核文件上传 - 中国烟草AI合同及卷宗审核系统" },
{
name: "description",
content: "上传待审核的合同文件、专卖许可证申请、行政处罚决定书等文档,进行AI智能审核"
},
{
name: "keywords",
content: "文件上传,合同审核,专卖许可证,行政处罚,AI审核,中国烟草"
}
];
};
// 文件类型定义为字符串类型,以适应从API动态获取的ID
export type FileType = string;
// 动态构建的文件类型标签映射
export const FILE_TYPE_LABELS: Record<string, string> = {};
// 优先级定义
export enum Priority {
NORMAL = "normal",
HIGH = "high",
URGENT = "urgent"
}
// 优先级标签映射
export const PRIORITY_LABELS: Record<Priority, string> = {
[Priority.NORMAL]: "普通",
[Priority.HIGH]: "优先",
[Priority.URGENT]: "紧急"
};
// 优先级中文映射
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const PRIORITY_TO_CHINESE: Record<Priority, string> = {
[Priority.NORMAL]: "普通",
[Priority.HIGH]: "优先",
[Priority.URGENT]: "紧急"
};
// 模拟API支持的存储类型
const STORAGE_TYPES = [
{ id: "minio", name: "MinIO对象存储" },
{ id: "local", name: "本地文件系统" },
{ id: "s3", name: "Amazon S3" }
];
// 文件上传完成后的操作选项
const AFTER_UPLOAD_OPTIONS = [
{ id: "list", name: "返回文档列表" },
{ id: "stay", name: "留在当前页面" },
{ id: "audit", name: "立即开始审核" }
];
// 上传的文件信息接口
export interface UploadedFile {
id: number;
name: string;
size: number;
type: string;
fileType: FileType;
priority: Priority;
status: DocumentStatus;
uploadTime: string;
processingInfo?: {
progress: number;
currentStep?: number;
};
}
// 修改文件上传函数部分,解决类型问题
async function handleFileUpload(
binaryData: ArrayBuffer,
fileName: string,
fileType: string,
documentType: FileType,
priority: Priority,
documentNumber: string | null,
remark: string | null,
isTestDocument: boolean,
documentId?: number | null,
isReupload: boolean = false,
jwtToken?: string,
attachments?: File[],
attributeType?: string
): Promise<FileUploadResponse> {
// console.log('【handleFileUpload】开始上传:', {
// fileName,
// fileSize: binaryData.byteLength,
// documentType,
// hasAttachments: !!(attachments && attachments.length > 0),
// attachmentCount: attachments?.length || 0
// });
const response = await uploadDocumentToServer(
binaryData,
fileName,
fileType,
documentType,
priority,
documentNumber,
remark,
isTestDocument,
documentId,
isReupload,
jwtToken,
attachments,
attributeType
);
// console.log('【handleFileUpload】uploadDocumentToServer返回:', {
// hasError: !!response.error,
// hasData: !!response.data,
// error: response.error,
// dataKeys: response.data ? Object.keys(response.data) : []
// });
if (response.error || !response.data) {
console.error('【handleFileUpload】上传失败:', response.error);
throw new Error(response.error || '上传失败');
}
// console.log('【handleFileUpload】返回数据:', response.data);
return response.data;
}
// 定义action返回数据的类型
type ActionData = {
errors?: {
fileType?: string;
file?: string;
};
success?: boolean;
message?: string;
fileId?: string;
fileType?: FileType;
priority?: Priority;
error?: string;
};
// action处理文件上传请求
export async function action({ request }: ActionFunctionArgs) {
try {
const formData = await request.formData();
// 获取文件和其他字段
const fileUpload = formData.get("file") as File | null;
const fileType = formData.get("fileType") as FileType;
const priority = formData.get("priority") as Priority;
const errors: Record<string, string> = {};
if (!fileType) {
errors.fileType = "上传文件之前请选择文件类型";
}
if (!fileUpload) {
errors.file = "未找到上传的文件";
}
// 如果有错误,返回错误信息
if (Object.keys(errors).length > 0) {
return Response.json({ errors });
}
// 获取文件信息
if (fileUpload) {
// console.log(`接收到文件: ${fileUpload.name}, 大小: ${fileUpload.size}, 类型: ${fileUpload.type}`);
}
// 注意: 在实际的Remix action中,我们无法直接处理文件内容
// 这里的代码仅用于模拟。在前端组件中,我们将实现实际的文件处理逻辑。
// 模拟文件上传成功响应
return Response.json({
success: true,
message: "文件上传请求已接收",
fileId: `file_${Date.now()}`,
fileType,
priority: priority || Priority.NORMAL,
});
} catch (error) {
console.error("文件上传失败:", error);
return Response.json(
{ success: false, error: "文件上传失败,请重试" },
{ status: 500 }
);
}
}
// 定义 loader 返回的数据类型
type LoaderData = {
documents: Document[];
documentTypes: DocumentType[];
mode: string;
userInfo?: {
user_id?: number;
username?: string;
nick_name?: string;
[key: string]: unknown;
} | null;
frontendJWT?: string | null;
userError?: string;
};
// 添加 loader 函数
export async function loader({ request }: LoaderFunctionArgs) {
try {
const loaderStart = Date.now();
// 获取用户会话信息
const sessionStart = Date.now();
const { getUserSession } = await import("~/api/login/auth.server");
const { userInfo, frontendJWT } = await getUserSession(request);
// console.log(`[loader 耗时] getUserSession: ${Date.now() - sessionStart}ms`);
// console.log('loader: 开始加载数据...');
const url = new URL(request.url);
const mode = url.searchParams.get("mode") || "create";
// 我们不能在服务器端访问 sessionStorage,所以在客户端组件中处理 documentTypeIds 过滤
// 并行加载文档和文档类型(分别计时)
const apiStart = Date.now();
const documentsPromise = (async () => {
const start = Date.now();
const result = await getTodayDocuments(userInfo, frontendJWT);
// console.log(`[loader 耗时] getTodayDocuments API: ${Date.now() - start}ms`);
return result;
})();
const typesPromise = (async () => {
const start = Date.now();
const result = await getDocumentTypes(frontendJWT);
// console.log(`[loader 耗时] getDocumentTypes API: ${Date.now() - start}ms`);
return result;
})();
const [documentsResponse, typesResponse] = await Promise.all([
documentsPromise,
typesPromise
]);
// console.log(`[loader 耗时] 并行API调用总耗时: ${Date.now() - apiStart}ms`);
// console.log(`[loader 耗时] loader总耗时: ${(Date.now() - loaderStart)/1000}s`);
// console.log('loader: 文档加载结果:', documentsResponse);
// console.log('loader: 文档类型加载结果:', typesResponse);
if (documentsResponse.error || typesResponse.error) {
// 如果是用户信息错误,返回特殊的错误状态
if (documentsResponse.error === '没有找到用户信息,请刷新重试') {
return Response.json({
documents: [],
documentTypes: typesResponse.data || [],
userInfo: null,
frontendJWT: null,
userError: documentsResponse.error
});
}
throw new Error(documentsResponse.error || typesResponse.error);
}
return Response.json({
mode,
documents: documentsResponse.data || [],
documentTypes: typesResponse.data || [],
userInfo, // 传递用户信息到客户端
frontendJWT // 传递JWT到客户端
});
} catch (error) {
console.error('loader: 加载数据失败:', error);
return Response.json({
documents: [],
documentTypes: [],
userInfo: null,
frontendJWT: null,
userError: undefined
});
}
}
// 文件上传页面组件
export default function FilesUpload() {
const [isNavigating, setIsNavigating] = useState(false)
const revalidator = useRevalidator()
// 获取 sessionStorage 中的 documentTypeIds 值
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [documentTypeIds, setDocumentTypeIds] = useState<number[] | null>(null);
// 使用 useLoaderData 获取初始数据
const loaderData = useLoaderData<LoaderData>();
// 状态管理
// 高级上传设置
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const [isTestDocument, setIsTestDocument] = useState(false);
const [fileType, setFileType] = useState<FileType | "">("");
const [priority, setPriority] = useState<Priority>(Priority.NORMAL);
const [documentNumber, setDocumentNumber] = useState<string>("");
const [remark, setRemark] = useState<string>("");
const [currentFiles, setCurrentFiles] = useState<File[]>([]);
const [attributeType, setAttributeType] = useState<string>(DEFAULT_CONTRACT_TYPE);
// 合同文件上传状态
// 这些变量暂时未使用,但保留以备将来扩展
const [isContractType, setIsContractType] = useState<boolean>(false);
const [contractMainFiles, setContractMainFiles] = useState<File[]>([]);
const [contractAttachmentFiles, setContractAttachmentFiles] = useState<File[]>([]);
const [contractTemplateFiles, setContractTemplateFiles] = useState<File[]>([]);
// 附件追加状态
const [showAttachmentUpload, setShowAttachmentUpload] = useState<boolean>(false);
const [selectedDocumentId, setSelectedDocumentId] = useState<number | null>(null);
const [selectedDocumentName, setSelectedDocumentName] = useState<string | null>(null);
const [attachmentFiles, setAttachmentFiles] = useState<File[]>([]);
const [attachmentMergeMode, setAttachmentMergeMode] = useState<'overwrite' | 'new'>('new');
const [attachmentRemark, setAttachmentRemark] = useState<string>("");
const [attachmentUploading, setAttachmentUploading] = useState<boolean>(false);
// 合同模板上传状态
const [showTemplateUpload, setShowTemplateUpload] = useState<boolean>(false);
const [templateFile, setTemplateFile] = useState<File | null>(null);
const [templateUploading, setTemplateUploading] = useState<boolean>(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadSpeed, setUploadSpeed] = useState("0KB/s");
const [uploadStage, setUploadStage] = useState<"idle" | "uploading" | "processing" | "completed" | "hadden">("idle");
const [processingSteps, setProcessingSteps] = useState<Step[]>([
{ title: "文件上传", description: "等待上传文件到服务器...", status: "waiting" },
{ title: "排队等待", description: "等待进入处理队列", status: "waiting" },
{ title: "文档转换拆分", description: "转换文档格式,拆分文档内容", status: "waiting" },
{ title: "评查点抽取", description: "DeepSeek 抽取中", status: "waiting" },
{ title: "评查点审核", description: "DeepSeek 评查中", status: "waiting" },
{ title: "审核准备", description: "文档已准备就绪,等待审核", status: "waiting" }
]);
const navigate = useNavigate();
// 队列文件状态
const [queueFiles, setQueueFiles] = useState<Document[]>([]);
const [documentTypesState, setDocumentTypesState] = useState<DocumentType[]>([]);
// 全局队列状态(用于显示排队统计)
const [globalQueueStatus, setGlobalQueueStatus] = useState<QueueStatus | null>(null);
// 在组件挂载时从 sessionStorage 获取 documentTypeIds
useEffect(() => {
try {
// 在客户端环境中执行
if (typeof window !== 'undefined') {
const typeIdsStr = sessionStorage.getItem('documentTypeIds');
const typeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null;
setDocumentTypeIds(typeIds);
// 根据 documentTypeIds 过滤文档类型和文档列表
filterDocumentTypes(typeIds, loaderData.documentTypes);
filterDocuments(typeIds);
// 如果包含合同类型(ID=1),自动选择合同文档类型
if (typeIds && typeIds.includes(1)) {
setIsContractType(true);
// 查找ID为1的合同文档类型
const contractType = loaderData.documentTypes.find(type => type.id === 1);
if (contractType) {
setFileType(contractType.id.toString());
// 清除可能存在的文件类型错误
setFileTypeError(null);
}
}
}
} catch (error) {
console.error('获取 sessionStorage 中的 documentTypeIds 失败:', error);
}
}, [loaderData]);
// 过滤文档类型列表
const filterDocumentTypes = (documentTypeIds: number[] | null, types: DocumentType[]) => {
if (!documentTypeIds || documentTypeIds.length === 0) {
// 如果没有特定的 documentTypeIds,使用原始数据
setDocumentTypesState(types);
return;
}
// 根据 documentTypeIds 过滤文档类型
const filteredTypes = types.filter(type => documentTypeIds.includes(type.id));
setDocumentTypesState(filteredTypes);
};
// 过滤文档列表
const filterDocuments = async (documentTypeIds: number[] | null) => {
if (!documentTypeIds || documentTypeIds.length === 0) {
// 如果没有特定的 documentTypeIds,使用原始数据
const documents = loaderData.documents;
setQueueFiles(documents);
// 启动状态检查定时器
startStatusChecker(documents);
return;
}
try {
// 使用 documentTypeIds 获取过滤后的文档列表
const response = await getTodayDocuments(loaderData.userInfo || undefined, loaderData.frontendJWT || undefined, documentTypeIds);
if (response.error) {
console.error('过滤文档列表失败:', response.error);
toastService.error(response.error);
// 失败时使用原始数据
setQueueFiles(loaderData.documents);
return;
}
const documents = response.data || [];
console.log('过滤文档列表成功:', documents);
setQueueFiles(documents);
// 数据加载完成后立即启动状态检查定时器
startStatusChecker(documents);
} catch (error) {
console.error('过滤文档列表失败:', error);
toastService.error('获取文档列表失败:'+(error instanceof Error ? error.message : '未知错误'));
// 出错时使用原始数据
const documents = loaderData.documents;
setQueueFiles(documents);
// 即使出错也启动状态检查定时器
startStatusChecker(documents);
}
};
// 构建文件类型标签映射
useEffect(() => {
// 清空之前的映射
Object.keys(FILE_TYPE_LABELS).forEach(key => {
delete FILE_TYPE_LABELS[key];
});
// 使用过滤后的文档类型构建新的映射
documentTypesState.forEach(type => {
FILE_TYPE_LABELS[type.id.toString()] = type.name;
});
}, [documentTypesState]);
// 上传完成后的文件信息列表
const [completedFiles, setCompletedFiles] = useState<UploadedFile[]>([]);
// 计时器引用 - 分离为三个独立的定时器
const uploadProgressIntervalRef = useRef<NodeJS.Timeout | null>(null);
const processingStatusIntervalRef = useRef<NodeJS.Timeout | null>(null);
const queueStatusIntervalRef = useRef<NodeJS.Timeout | null>(null); // 原 statusCheckIntervalRef
// UploadArea组件引用
const uploadAreaRef = useRef<UploadAreaRef>(null);
// 添加合同文件上传区域引用
const contractMainFileRef = useRef<UploadAreaRef>(null);
const contractAttachmentFileRef = useRef<UploadAreaRef>(null);
// 表单提交引用
const formRef = useRef<HTMLFormElement>(null);
// 获取action返回的数据
const actionData = useActionData<ActionData>();
// 添加一个本地状态来跟踪文件类型错误
const [fileTypeError, setFileTypeError] = useState<string | null>(
actionData?.errors?.fileType || null
);
// 监听actionData变化,当有fileType错误时更新fileTypeError状态
useEffect(() => {
if (actionData?.errors?.fileType) {
setFileTypeError(actionData.errors.fileType);
}
}, [actionData]);
// 检查用户错误并显示 toast 提示
useEffect(() => {
if (loaderData.userError) {
toastService.error(loaderData.userError);
}
}, [loaderData.userError]);
// 添加组件挂载状态引用
const isMountedRef = useRef<boolean>(true);
// useEffect 处理上传队列状态检查定时器 - 只在组件卸载时清除
useEffect(() => {
console.log('设置上传队列状态检查定时器');
// 标记组件已挂载
isMountedRef.current = true;
// 只在组件卸载时清除
return () => {
// console.log('组件卸载,清除上传队列状态检查定时器');
// 标记组件已卸载
isMountedRef.current = false;
if (queueStatusIntervalRef.current) {
clearInterval(queueStatusIntervalRef.current);
queueStatusIntervalRef.current = null;
}
};
}, []);
// 全局队列状态轮询 - 每10秒获取一次队列统计
useEffect(() => {
// 立即获取一次队列状态
const fetchQueueStatus = async () => {
try {
const response = await getQueueStatus();
if (response.data && isMountedRef.current) {
setGlobalQueueStatus(response.data);
}
} catch (error) {
console.error('获取全局队列状态失败:', error);
}
};
fetchQueueStatus();
// 设置轮询定时器
const intervalId = setInterval(() => {
if (isMountedRef.current) {
fetchQueueStatus();
}
}, 10000);
return () => {
clearInterval(intervalId);
};
}, []);
// 启动状态检查定时器的函数
const startStatusChecker = (files: Document[]) => {
console.log('启动状态检查定时器,队列文件数量:', files.length);
// 清除之前的定时器
if (queueStatusIntervalRef.current) {
clearInterval(queueStatusIntervalRef.current);
}
// 只有当有文件时才启动定时器
if (files.length > 0) {
// 立即检查一次
checkQueueStatusWithFiles(files);
// 启动定时器
queueStatusIntervalRef.current = setInterval(() => {
if (isMountedRef.current) {
// 获取最新的queueFiles状态
setQueueFiles(currentFiles => {
checkQueueStatusWithFiles(currentFiles);
return currentFiles; // 不改变状态,只是为了获取最新值
});
}
}, 10000);
}
};
// 检查指定文件列表的状态
const checkQueueStatusWithFiles = async (files: Document[]) => {
try {
// console.log('开始检查队列状态,当前队列文件:', files);
// 直接从sessionStorage读取documentTypeIds,避免异步状态更新问题
const typeIdsStr = typeof window !== 'undefined' ? sessionStorage.getItem('documentTypeIds') : null;
const currentDocumentTypeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null;
// console.log('从sessionStorage读取的documentTypeIds:', currentDocumentTypeIds);
// 获取所有未完成的文档
const incompleteFiles = files.filter(file =>
file.status !== DocumentStatus.PROCESSED && file.id
);
if (incompleteFiles.length === 0) {
console.log('没有未完成的文档,跳过状态检查');
return;
}
let statusResponse;
// 如果是合同类型(ID=1),需要分类处理
// console.log('当前documentTypeIds:', currentDocumentTypeIds);
// if (currentDocumentTypeIds && currentDocumentTypeIds.includes(1)) {
// // 分类文档ID
// const mainDocumentIds: number[] = [];
// const attachmentIds: number[] = [];
// incompleteFiles.forEach(file => {
// // 检查是否存在template_contract_path属性来判断是否为合同附件
// if ('template_contract_path' in file && file.template_contract_path) {
// attachmentIds.push(file.id);
// } else {
// mainDocumentIds.push(file.id);
// }
// });
// // console.log('合同主文件ID:', mainDocumentIds);
// // console.log('合同附件ID:', attachmentIds);
// // 分别查询状态
// statusResponse = await getDocumentsStatus(mainDocumentIds, attachmentIds, loaderData.frontendJWT || undefined);
// }
// else
{
// 非合同类型,使用原有逻辑
const incompleteIds = incompleteFiles.map(file => file.id);
// console.log('未完成的文档ID:', incompleteIds);
statusResponse = await getDocumentsStatus(incompleteIds, undefined, loaderData.frontendJWT || undefined);
}
// console.log('状态检查响应:', statusResponse);
if (statusResponse.data) {
// 更新队列中的文档状态,使用批量更新避免频繁渲染
setQueueFiles(prevFiles => {
let hasChanges = false;
const updatedFiles = prevFiles.map(file => {
const updatedStatus = statusResponse.data.find(doc => doc.id === file.id);
if (updatedStatus && updatedStatus.status !== file.status) {
console.log(`文档 ${file.id} 状态更新: ${file.status} -> ${updatedStatus.status}`);
hasChanges = true;
return { ...file, status: updatedStatus.status };
}
return file;
});
// 只有在确实有变化时才返回新数组
return hasChanges ? updatedFiles : prevFiles;
});
}
} catch (error) {
console.error('检查文档状态失败:', error);
}
};
// 处理文件选择
const handleFilesSelected = (files: FileList) => {
if (files.length > 0) {
// 验证文件类型,支持PDF和Word文件
const validFiles: File[] = [];
let hasInvalidFiles = false;
Array.from(files).forEach(file => {
const fileName = file.name.toLowerCase();
const isValidType =
file.type === 'application/pdf' || fileName.endsWith('.pdf') ||
file.type === 'application/msword' || fileName.endsWith('.doc') ||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || fileName.endsWith('.docx');
if (isValidType) {
validFiles.push(file);
} else {
hasInvalidFiles = true;
}
});
if (hasInvalidFiles) {
// 显示错误提示
messageService.error('只支持.pdf、.docx格式的文件', {
title: '文件类型错误',
confirmText: '确定',
cancelText: '',
});
}
if (validFiles.length > 0) {
if (fileType) {
// 通过 checkAndPrepareUpload 进行重名检查后再上传
checkAndPrepareUpload(validFiles, [], []);
} else {
// 如果没有选择文件类型,先保存文件等待用户选择
setCurrentFiles(validFiles);
}
}
}
};
// 处理文件类型变化
const handleFileTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
// 确保只有选择了有效的文件类型才进行设置
if (value) {
// console.log('【调试-handleFileTypeChange】文件类型变更为:', value);
setFileType(value as FileType);
// 立即清除错误状态
setFileTypeError(null);
// 检查是否选择了合同类型
const selectedType = loaderData.documentTypes.find(t => t.id.toString() === value);
const isContract = !!(selectedType && selectedType.name.includes('合同'));
// console.log('【调试-handleFileTypeChange】文件类型检查:', {
// selectedType,
// isContract,
// typeName: selectedType?.name,
// currentFiles: currentFiles.length
// });
setIsContractType(isContract);
// 重置文件状态
setContractMainFiles([]);
setContractAttachmentFiles([]);
setCurrentFiles([]);
// 如果已经有选中的文件,且选择了文件类型,且不是合同类型,则开始上传
if (currentFiles.length > 0 && !isContract) {
// console.log('【调试-handleFileTypeChange】自动开始上传非合同类型文件');
// 通过 checkAndPrepareUpload 进行重名检查后再上传
checkAndPrepareUpload(currentFiles, [], []);
} else if (currentFiles.length > 0 && isContract) {
// console.log('【调试-handleFileTypeChange】合同类型需要手动点击开始上传按钮');
// 合同类型不自动上传,需要用户先上传主文件和附件,然后点击开始上传按钮
setCurrentFiles([]);
}
} else {
setFileType("");
setIsContractType(false);
// 如果用户选择了空选项,显示错误信息
setFileTypeError("上传文件之前请选择文件类型");
}
};
// 处理合同主文件选择
const handleContractMainFilesSelected = (files: FileList) => {
try {
// console.log('【调试-handleContractMainFilesSelected】开始处理合同主文件选择, 文件数量:', files.length);
// 检查组件是否已卸载
if (!isMountedRef.current) {
console.error('【调试-handleContractMainFilesSelected】组件已卸载,取消处理');
return;
}
if (files.length > 0) {
// 验证文件类型,支持PDF和Word
const validFiles: File[] = [];
let hasInvalidFiles = false;
Array.from(files).forEach(file => {
const fileName = file.name.toLowerCase();
const isValidType =
file.type === 'application/pdf' || fileName.endsWith('.pdf') ||
file.type === 'application/msword' || fileName.endsWith('.doc') ||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || fileName.endsWith('.docx');
if (isValidType) {
validFiles.push(file);
} else {
hasInvalidFiles = true;
console.error(`【调试-handleContractMainFilesSelected】无效的文件类型: ${file.name}, 类型: ${file.type}`);
}
});
if (hasInvalidFiles) {
// 显示错误提示
console.error('【调试-handleContractMainFilesSelected】存在无效的文件类型');
messageService.error('只支持.pdf、.docx格式的文件', {
title: '文件类型错误',
confirmText: '确定',
cancelText: '',
});
}
if (validFiles.length > 0 && isMountedRef.current) {
// console.log('【调试-handleContractMainFilesSelected】有效文件数量:', validFiles.length);
// console.log('【调试-handleContractMainFilesSelected】有效文件:', validFiles.map(f => ({ name: f.name, size: f.size, type: f.type })));
setContractMainFiles(validFiles);
} else {
console.error('【调试-handleContractMainFilesSelected】没有有效的PDF文件或组件已卸载');
}
} else {
// console.log('【调试-handleContractMainFilesSelected】未选择任何文件');
}
} catch (error) {
console.error('【调试-handleContractMainFilesSelected】处理合同主文件选择时发生错误:', error);
}
};
// 处理合同附件选择
const handleContractAttachmentFilesSelected = (files: FileList) => {
try {
// console.log('【调试-handleContractAttachmentFilesSelected】开始处理合同附件选择, 文件数量:', files.length);
// 检查组件是否已卸载
if (!isMountedRef.current) {
console.error('【调试-handleContractAttachmentFilesSelected】组件已卸载,取消处理');
return;
}
if (files.length > 0) {
// 验证文件类型,支持PDF和Word
const validFiles: File[] = [];
let hasInvalidFiles = false;
Array.from(files).forEach(file => {
const fileName = file.name.toLowerCase();
const isValidType =
file.type === 'application/pdf' || fileName.endsWith('.pdf') ||
file.type === 'application/msword' || fileName.endsWith('.doc') ||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || fileName.endsWith('.docx');
if (isValidType) {
validFiles.push(file);
} else {
hasInvalidFiles = true;
console.error(`【调试-handleContractAttachmentFilesSelected】无效的文件类型: ${file.name}, 类型: ${file.type}`);
}
});
if (hasInvalidFiles) {
// 显示错误提示
console.error('【调试-handleContractAttachmentFilesSelected】存在无效的文件类型');
messageService.error('只支持.pdf、.docx格式的文件', {
title: '文件类型错误',
confirmText: '确定',
cancelText: '',
});
}
if (validFiles.length > 0 && isMountedRef.current) {
// console.log('【调试-handleContractAttachmentFilesSelected】有效文件数量:', validFiles.length);
// console.log('【调试-handleContractAttachmentFilesSelected】有效文件:', validFiles.map(f => ({ name: f.name, size: f.size, type: f.type })));
setContractAttachmentFiles(validFiles);
} else {
console.error('【调试-handleContractAttachmentFilesSelected】没有有效的PDF文件或组件已卸载');
}
} else {
// console.log('【调试-handleContractAttachmentFilesSelected】未选择任何文件');
}
} catch (error) {
console.error('【调试-handleContractAttachmentFilesSelected】处理合同附件选择时发生错误:', error);
}
};
// 处理合同模板文件选择
const handleContractTemplateFilesSelected = (files: FileList) => {
try {
console.log('【调试-handleContractTemplateFilesSelected】开始处理合同模板选择, 文件数量:', files.length);
// 检查组件是否已卸载
if (!isMountedRef.current) {
console.error('【调试-handleContractTemplateFilesSelected】组件已卸载,取消处理');
return;
}
if (files.length > 0) {
// 验证文件类型,支持PDF和Word
const validFiles: File[] = [];
let hasInvalidFiles = false;
Array.from(files).forEach(file => {
const fileName = file.name.toLowerCase();
const isValidType =
file.type === 'application/pdf' || fileName.endsWith('.pdf') ||
file.type === 'application/msword' || fileName.endsWith('.doc') ||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || fileName.endsWith('.docx');
if (isValidType) {
validFiles.push(file);
} else {
hasInvalidFiles = true;
console.error(`【调试-handleContractTemplateFilesSelected】无效的文件类型: ${file.name}, 类型: ${file.type}`);
}
});
if (hasInvalidFiles) {
// 显示错误提示
console.error('【调试-handleContractTemplateFilesSelected】存在无效的文件类型');
messageService.error('只支持.pdf、.docx格式的文件', {
title: '文件类型错误',
confirmText: '确定',
cancelText: '',
});
}
if (validFiles.length > 0 && isMountedRef.current) {
console.log('【调试-handleContractTemplateFilesSelected】有效文件数量:', validFiles.length);
console.log('【调试-handleContractTemplateFilesSelected】有效文件:', validFiles.map(f => ({ name: f.name, size: f.size, type: f.type })));
setContractTemplateFiles(validFiles);
} else {
console.error('【调试-handleContractTemplateFilesSelected】没有有效的文件或组件已卸载');
}
} else {
console.log('【调试-handleContractTemplateFilesSelected】未选择任何文件');
}
} catch (error) {
console.error('【调试-handleContractTemplateFilesSelected】处理合同模板选择时发生错误:', error);
}
};
// 处理附件追加文件选择
const handleAttachmentFilesSelected = (files: FileList) => {
try {
console.log('【附件追加】开始处理附件文件选择, 文件数量:', files.length);
if (files.length > 0) {
// 检查主文件类型
const selectedDocument = queueFiles.find(doc => doc.id === selectedDocumentId);
const isMainFileDocx = selectedDocument?.path?.toLowerCase().endsWith('.docx');
// 验证文件类型,支持PDF、Word、ZIP、RAR
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/msword' || fileName.endsWith('.doc') ||
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;
console.error(`【附件追加】主文件为DOCX格式时不允许上传PDF附件: ${file.name}`);
return;
}
if (isValidType) {
validFiles.push(file);
} else {
hasInvalidFiles = true;
console.error(`【附件追加】无效的文件类型: ${file.name}, 类型: ${file.type}`);
}
});
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);
console.log('【附件追加】有效文件数量:', validFiles.length);
}
}
} catch (error) {
console.error('【附件追加】处理文件选择时发生错误:', error);
}
};
// 处理附件追加上传
const handleAttachmentUpload = async () => {
if (!selectedDocumentId || attachmentFiles.length === 0) {
toastService.error('请选择文档和附件文件');
return;
}
try {
setAttachmentUploading(true);
const result = await appendContractAttachments(
selectedDocumentId,
attachmentFiles,
attachmentMergeMode,
true, // isReprocess
attachmentRemark || undefined,
loaderData.frontendJWT as string | undefined
);
if (result.error) {
throw new Error(result.error);
}
toastService.success('附件追加成功!');
// 重置状态
setAttachmentFiles([]);
setAttachmentRemark("");
setShowAttachmentUpload(false);
setSelectedDocumentId(null);
setSelectedDocumentName(null)
// 刷新文档列表
await filterDocuments(documentTypeIds);
} catch (error) {
console.error('【附件追加】上传失败:', error);
toastService.error(error instanceof Error ? error.message : '附件追加失败');
} finally {
setAttachmentUploading(false);
}
};
// 处理合同模板文件选择
const handleTemplateFileSelected = (files: FileList) => {
try {
console.log('【合同模板上传】开始处理模板文件选择, 文件数量:', files.length);
if (files.length > 0) {
const file = files[0];
// 验证文件类型,支持PDF和Word
const fileName = file.name.toLowerCase();
const isValidType =
file.type === 'application/pdf' || fileName.endsWith('.pdf') ||
file.type === 'application/msword' || fileName.endsWith('.doc') ||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || fileName.endsWith('.docx');
if (isValidType) {
setTemplateFile(file);
console.log('【合同模板上传】有效文件:', file.name);
} else {
messageService.error('只支持.pdf、.docx格式的文件', {
title: '文件类型错误',
confirmText: '确定',
cancelText: '',
});
}
}
} catch (error) {
console.error('【合同模板上传】处理文件选择时发生错误:', error);
}
};
// 处理合同模板上传
const handleTemplateUpload = async () => {
if (!selectedDocumentId || !templateFile) {
toastService.error('请选择文档和模板文件');
return;
}
try {
setTemplateUploading(true);
const result = await uploadContractTemplate(
templateFile,
selectedDocumentId,
undefined, // comparisonId
loaderData.frontendJWT as string | undefined
);
if (result.error) {
throw new Error(result.error);
}
toastService.success('合同模板上传成功!');
// 重置状态
setTemplateFile(null);
setShowTemplateUpload(false);
setSelectedDocumentId(null);
setSelectedDocumentName(null)
// 刷新文档列表
await filterDocuments(documentTypeIds);
} catch (error) {
console.error('【合同模板上传】上传失败:', error);
toastService.error(error instanceof Error ? error.message : '合同模板上传失败');
} finally {
setTemplateUploading(false);
}
};
// 合同专用:首传即合并的上传链路
const startContractUpload = async (mainFiles: File[], attachmentFiles: File[], templateFiles: File[] = []) => {
try {
// 只允许一个主文件
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);
setUploadStage("uploading");
setUploadProgress(0);
// 步骤一:置为进行中
{
const updatedSteps = [...processingSteps];
updatedSteps[0].status = "active";
updatedSteps[0].description = `正在上传 ${filesForProgress.length} 个文件到服务器...`;
setProcessingSteps(updatedSteps);
}
// 计算总大小并开启与旧逻辑一致的模拟进度(按时间推进到 95%)
const totalSize = filesForProgress.reduce((sum, f) => sum + (f?.size || 0), 0);
const startTime = Date.now();
let lastUpdateTime = startTime;
let lastRatio = 0;
const estimatedUploadTime = Math.max(
(totalSize / (1024 * 1024)) / 3 * 1000, // 3MB/s 估算
1000
);
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
}
uploadProgressIntervalRef.current = setInterval(() => {
const now = Date.now();
const deltaSec = (now - lastUpdateTime) / 1000;
const ratio = Math.min((now - startTime) / estimatedUploadTime, 0.95);
// 计算瞬时速度(基于比例变化)
const deltaRatio = Math.max(ratio - lastRatio, 0);
const bytesPerSec = deltaSec > 0 ? (totalSize * deltaRatio) / deltaSec : 0;
lastRatio = ratio;
lastUpdateTime = now;
setUploadSpeed(`${formatFileSize(bytesPerSec)}/s`);
setUploadProgress(parseFloat((ratio * 100).toFixed(2)));
}, 200);
// 转二进制
const binaryData = await uploadFileToBinary(mainFile);
// 首传:将附件通过 attachments 一起带上
const uploadResp = await handleFileUpload(
binaryData,
mainFile.name,
mainFile.type,
fileType as FileType,
priority,
documentNumber || null,
remark || null,
isTestDocument,
null,
false,
loaderData.frontendJWT || undefined,
attachmentFiles,
attributeType
);
// console.log('【合同上传】服务器响应数据:', uploadResp);
// console.log('【合同上传】响应详情:', {
// success: uploadResp.success,
// hasResult: !!uploadResp.result,
// error: uploadResp.error,
// fullResponse: JSON.stringify(uploadResp)
// });
if (!uploadResp.success) {
throw new Error(uploadResp.error || '上传失败,服务器返回success=false');
}
if (!uploadResp.result) {
throw new Error('主文件上传失败:服务器未返回文档信息');
}
const documentId = uploadResp.result.id;
// 可选:模板上传
if (templateFiles && templateFiles.length > 0) {
const tpl = templateFiles[0];
const tplResult = await uploadContractTemplate(
tpl,
documentId,
undefined,
loaderData.frontendJWT as string | undefined
);
if ('error' in tplResult && tplResult.error) {
throw new Error(tplResult.error);
}
}
// 完成:清理进度定时器并置满
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
uploadProgressIntervalRef.current = null;
}
setUploadProgress(100);
setUploadSpeed('完成');
toastService.success('上传成功');
// 构造已上传文件并进入处理流程(保持与旧逻辑一致,点亮步骤条)
const uploadedFiles: UploadedFile[] = [
{
id: uploadResp.result.id,
name: uploadResp.result.file_name,
size: uploadResp.result.file_size,
type: mainFile.type,
fileType: fileType as FileType,
priority,
status: DocumentStatus.WAITING,
uploadTime: getCurrentTime(),
processingInfo: { progress: 0, currentStep: 0 }
}
];
setCompletedFiles(uploadedFiles);
startProcessing(uploadedFiles);
// 刷新队列
await filterDocuments(documentTypeIds);
} catch (error) {
console.error('合同首传上传失败:', error);
messageService.error(`合同上传失败:${error instanceof Error ? error.message : '未知错误'}`);
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
uploadProgressIntervalRef.current = null;
}
// 清空合同模板文件缓存
setContractTemplateFiles([]);
console.log('【合同上传失败】已清空合同模板文件缓存');
resetUpload();
}
};
// 检查并准备上传
const checkAndPrepareUpload = async (mainFiles: File[], attachmentFiles: File[], templateFiles: File[] = []) => {
try {
// console.log('【调试-checkAndPrepareUpload】开始检查并准备上传文件', {
// mainFilesCount: mainFiles.length,
// attachmentFilesCount: attachmentFiles.length,
// templateFilesCount: templateFiles.length,
// fileType
// });
// 检查组件是否已卸载
if (!isMountedRef.current) {
console.error('【调试-checkAndPrepareUpload】组件已卸载,取消操作');
return;
}
// 检查是否选择了文件类型
if (!fileType) {
console.error('【调试-checkAndPrepareUpload】未选择文件类型');
toastService.error('请先选择文件类型');
return;
}
// 检查是否为合同类型
const selectedType = loaderData.documentTypes.find(t => t.id.toString() === fileType);
const isContract = !!(selectedType && selectedType.name.includes('合同'));
// console.log('【调试-checkAndPrepareUpload】文件类型检查', {
// selectedType,
// isContract,
// typeName: selectedType?.name
// });
if (isContract) {
// 合同类型:走首传即合并的专用链路
if(mainFiles.length === 0) {
toastService.error('请上传合同主文件');
return;
}
startContractUpload(mainFiles, attachmentFiles, templateFiles);
return;
}
if (mainFiles.length > 0) {
// 合并所有文件
let allFiles = [...mainFiles];
// console.log('【调试-checkAndPrepareUpload】合并文件后总数:', allFiles.length);
// 检查组件是否已卸载
if (!isMountedRef.current) {
console.error('【调试-checkAndPrepareUpload】组件已卸载,取消操作');
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, '个文件');
if (fileType) {
try {
// 再次检查组件是否已卸载
if (!isMountedRef.current) {
console.error('【调试-checkAndPrepareUpload】组件已卸载,取消上传');
return;
}
// console.log('【调试-checkAndPrepareUpload】开始调用startUpload函数');
// 使用 setTimeout 延迟调用,确保状态已更新
setTimeout(() => {
if (isMountedRef.current) {
try {
startUpload(allFiles);
} catch (delayedError) {
console.error('【调试-checkAndPrepareUpload】延迟调用startUpload失败:', delayedError);
toastService.error(`准备上传文件失败: ${delayedError instanceof Error ? delayedError.message : '未知错误'}`);
}
} else {
console.error('【调试-checkAndPrepareUpload】组件已卸载,取消延迟上传');
}
}, 0);
} catch (uploadError) {
console.error('【调试-checkAndPrepareUpload】调用startUpload失败:', uploadError);
// 检查组件是否已卸载
if (isMountedRef.current) {
toastService.error(`准备上传文件失败: ${uploadError instanceof Error ? uploadError.message : '未知错误'}`);
}
}
} else {
console.error('【调试-checkAndPrepareUpload】未选择文件类型,无法上传');
toastService.error('请选择文件类型');
}
} else {
console.error('【调试-checkAndPrepareUpload】没有文件可上传');
toastService.error('请选择要上传的文件');
}
} catch (error) {
console.error('【调试-checkAndPrepareUpload】准备上传文件过程中发生错误:', error);
// 检查组件是否已卸载
if (isMountedRef.current) {
toastService.error(`准备上传文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
}
};
// 开始上传文件
const startUpload = async (files: File[]) => {
try {
console.log('【调试-startUpload】开始上传过程,文件数量:', files.length);
// 检查组件是否已卸载
if (!isMountedRef.current) {
console.error('【调试-startUpload】组件已卸载,取消上传');
return;
}
// 再次验证所有文件类型,确保只有PDF文件
const invalidFiles = files.filter(file => {
const fileName = file.name.toLowerCase();
const isValidType =
file.type === 'application/pdf' || fileName.endsWith('.pdf') ||
file.type === 'application/msword' || fileName.endsWith('.doc') ||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || fileName.endsWith('.docx');
return !isValidType;
});
if (invalidFiles.length > 0) {
console.error('【调试-startUpload】文件类型验证失败:', invalidFiles.map(f => f.name));
throw new Error('只支持.pdf、.docx格式的文件');
}
setUploadStage("uploading");
setUploadProgress(0);
// 计算总文件大小
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
let uploadedSize = 0;
// console.log('【调试-startUpload】总文件大小:', formatFileSize(totalSize));
// 更新步骤状态
const updatedSteps = [...processingSteps];
updatedSteps[0].status = "active";
updatedSteps[0].description = `正在上传 ${files.length} 个文件到服务器...`;
// 检查组件是否已卸载
if (isMountedRef.current) {
setProcessingSteps(updatedSteps);
} else {
// console.log('【调试-startUpload】组件已卸载,不更新处理步骤');
return;
}
// 转换文件为二进制格式
// console.log("【调试-startUpload】开始转换文件到二进制格式...");
// 模拟上传进度
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
}
const startTime = Date.now();
let lastUploadedSize = 0;
let lastUpdateTime = startTime;
uploadProgressIntervalRef.current = setInterval(() => {
const currentTime = Date.now();
const timeElapsed = (currentTime - lastUpdateTime) / 1000; // 使用最近一次更新的时间间隔
const currentSpeed = timeElapsed > 0 ? (uploadedSize - lastUploadedSize) / timeElapsed : 0; // 字节/秒
lastUploadedSize = uploadedSize;
lastUpdateTime = currentTime;
// 更新上传速度显示
setUploadSpeed(`${formatFileSize(currentSpeed)}/s`);
// 更新进度 - 保留2位小数
const progress = Math.min((uploadedSize / totalSize) * 100, 99.99);
setUploadProgress(parseFloat(progress.toFixed(2)));
}, 200); // 改为200ms更新一次,提供更准确的速度计算
// 上传所有文件
const uploadedFiles: UploadedFile[] = [];
let temp_n = 0;
let firstFileDocumentId: number | null = null; // 保存第一个文件的document_id
for (const file of files) {
temp_n++;
console.log('【调试-startUpload】上传文件:','第', temp_n, '个文件', file.name);
try {
// console.log(`【调试-startUpload】准备上传文件: ${file.name}, 大小: ${formatFileSize(file.size)}`);
// 转换文件为二进制格式
// console.log(`【调试-startUpload】开始转换文件 ${file.name} 为二进制格式`);
let binaryData: ArrayBuffer;
try {
binaryData = await uploadFileToBinary(file);
// console.log(`【调试-startUpload】文件 ${file.name} 二进制转换成功,大小: ${binaryData.byteLength} 字节`);
} catch (binaryError) {
console.error(`【调试-startUpload】文件 ${file.name} 二进制转换失败:`, binaryError);
throw new Error(`文件 ${file.name} 转换失败: ${binaryError instanceof Error ? binaryError.message : '未知错误'}`);
}
let response: FileUploadResponse;
// console.log(`【调试-startUpload】开始上传文件 ${file.name} 到服务器,文件类型: ${fileType}`);
try {
// 上传文件
// 检查组件是否已卸载
if (!isMountedRef.current) {
console.error('【调试-startUpload】组件已卸载,取消上传');
return { success: false, error: '组件已卸载' } as FileUploadResponse;
}
// console.log(`【调试-startUpload】准备上传文件 ${file.name} 到服务器`);
// 创建基于时间的渐进式进度模拟
const startUploadTime = Date.now();
// 根据文件大小动态估算上传时间,考虑网络速度
const estimatedUploadTime = Math.max(
file.size / (1024 * 1024) / 3 * 1000, // 假设3MB/s的速度,1MB需要1/3秒
1000 // 最小1秒
);
let progressInterval: NodeJS.Timeout | null = null;
// 开始渐进式进度更新
const progressPromise = new Promise<void>((resolve) => {
progressInterval = setInterval(() => {
const elapsed = Date.now() - startUploadTime;
const progressRatio = Math.min(elapsed / estimatedUploadTime, 0.95); // 最大95%
// 计算当前文件的进度贡献
const fileProgress = progressRatio * file.size;
const previousFilesSize = files.slice(0, temp_n - 1).reduce((sum, f) => sum + f.size, 0);
uploadedSize = previousFilesSize + fileProgress;
// 如果接近完成,停止进度更新并resolve
if (progressRatio >= 0.95) {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
resolve();
}
}, 100); // 改为100ms更新一次,提供更流畅的进度
});
// 使用Promise.race添加超时处理
const uploadPromise = handleFileUpload(
binaryData,
file.name,
file.type,
fileType as FileType,
priority,
documentNumber || null,
remark || null,
isTestDocument,
temp_n > 1 ? firstFileDocumentId : null, // 第二个文件及以后使用第一个文件的document_id
false,
loaderData.frontendJWT || undefined,
undefined,
attributeType
);
const timeoutPromise = new Promise<FileUploadResponse>((_, reject) => {
setTimeout(() => {
reject(new Error('上传超时'));
}, 600000); // 10分钟超时
});
// 并行执行上传和进度更新
const [uploadResult] = await Promise.all([
Promise.race([uploadPromise, timeoutPromise]),
progressPromise
]);
// 清除进度定时器
if (progressInterval) {
clearInterval(progressInterval);
}
// 再次检查组件是否已卸载
if (!isMountedRef.current) {
console.error('【调试-startUpload】组件已卸载,忽略上传响应');
return;
}
// 检查上传结果
if (!uploadResult.success || !uploadResult.result) {
throw new Error(uploadResult.error || '上传失败');
}
response = uploadResult;
// 保存第一个文件的document_id,用于后续附件上传
if (temp_n === 1 && response.result?.id) {
firstFileDocumentId = response.result.id;
console.log('【调试-startUpload】保存第一个文件的document_id:', firstFileDocumentId);
}
// console.log(`【调试-startUpload】文件 ${file.name} 上传响应:`, response);
} catch (error) {
// 检查组件是否已卸载
if (!isMountedRef.current) {
console.error('【调试-startUpload】组件已卸载,忽略上传错误');
return;
}
console.error(`【调试-startUpload】文件 ${file.name} 上传错误:`, error);
throw new Error(`文件 ${file.name} 上传失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
if (!response.success || !response.result) {
console.error(`【调试-startUpload】文件 ${file.name} 上传失败:`, response.error);
throw new Error(response.error || `文件 ${file.name} 上传失败`);
}
// 更新已上传大小
uploadedSize += file.size;
// 创建新的文件对象
const newFile: UploadedFile = {
id: response.result.id,
name: response.result.file_name,
size: response.result.file_size,
type: file.type,
fileType: fileType as FileType,
priority,
status: DocumentStatus.WAITING,
uploadTime: getCurrentTime(),
processingInfo: {
progress: 0,
currentStep: 0
}
};
// console.log(`【调试-startUpload】文件 ${file.name} 上传成功,文件ID: ${newFile.id}`);
uploadedFiles.push(newFile);
} catch (fileError) {
console.error(`【调试-startUpload】处理文件 ${file.name} 时发生错误:`, fileError);
// 继续抛出错误,让外层catch捕获
throw fileError;
}
}
// 清除进度定时器
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
}
// 更新上传状态
setUploadProgress(100);
setUploadSpeed("完成");
// 更新队列
const newDocuments: Document[] = uploadedFiles.map(file => {
// 确保id能够被正确解析为数字
const id = file.id;
return {
id,
name: file.name,
type_id: fileType ? parseInt(fileType) : 0,
file_size: file.size,
status: DocumentStatus.CUTTING,
created_at: new Date().toISOString()
};
});
// console.log(`【调试-startUpload】所有文件上传完成,更新队列`);
setQueueFiles(prev => [...newDocuments, ...prev]);
// 设置当前文件为已上传的文件
setCompletedFiles(uploadedFiles);
// 完成上传后开始处理流程
// console.log(`【调试-startUpload】开始文件处理流程`);
startProcessing(uploadedFiles);
} catch (error) {
console.error("【调试-startUpload】文件上传过程发生错误:", error);
// 更新步骤状态为错误
const errorSteps = [...processingSteps];
errorSteps[0].status = "error";
errorSteps[0].description = `上传文件失败: ${error instanceof Error ? error.message : '未知错误'}`;
setProcessingSteps(errorSteps);
// 清除进度定时器
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
}
// 显示错误提示
messageService.error(`文件上传失败:${error instanceof Error ? error.message : '未知错误'}`, {
title: '文件上传失败',
confirmText: '确定',
cancelText: '',
onConfirm: () => {
resetUpload();
}
});
resetUpload();
// 抛出错误,让React错误边界捕获并显示
throw error;
}
};
// 开始处理上传的文件
const startProcessing = (files: UploadedFile[]) => {
try {
// console.log('【调试-startProcessing】开始处理上传的文件:', files.length, '个文件');
// 检查组件是否已卸载
if (!isMountedRef.current) {
console.error('【调试-startProcessing】组件已卸载,取消处理');
return;
}
// 更新上传阶段
setUploadStage("processing");
// 更新步骤状态:上传完成,进入排队等待
const updatedSteps = processingSteps.map(step => ({...step}));
updatedSteps[0].status = "done";
updatedSteps[0].description = "文件已成功上传到服务器";
updatedSteps[1].status = "active";
updatedSteps[1].description = "文档正在排队等待处理...";
setProcessingSteps(updatedSteps);
// 获取文件ID列表
const fileIds = files.map(file => file.id).filter(id => id > 0);
// console.log('【调试-startProcessing】文件ID列表:', fileIds);
if (fileIds.length === 0) {
console.error('【调试-startProcessing】没有有效的文件ID,无法开始处理');
throw new Error('没有有效的文件ID,无法开始处理');
}
// console.log('【调试-startProcessing】开始处理文件,设置文件处理进度定时器');
// 清除之前的进度定时器(如果存在)
if (processingStatusIntervalRef.current) {
clearInterval(processingStatusIntervalRef.current);
}
// 立即开始检查状态
try {
// console.log('【调试-startProcessing】立即开始检查处理状态');
checkProcessingStatus(fileIds);
} catch (statusError) {
console.error('【调试-startProcessing】首次检查状态失败:', statusError);
}
// 设置文件处理进度定时器,每10秒检查一次状态
processingStatusIntervalRef.current = setInterval(() => {
// console.log('【调试-startProcessing】文件处理进度定时器触发,检查文件状态');
try {
checkProcessingStatus(fileIds);
} catch (intervalError) {
console.error('【调试-startProcessing】定时检查状态失败:', intervalError);
// 不要抛出,继续尝试
}
}, 10000);
} catch (error) {
console.error('【调试-startProcessing】处理文件过程中发生错误:', error);
// 清除进度定时器
if (processingStatusIntervalRef.current) {
clearInterval(processingStatusIntervalRef.current);
processingStatusIntervalRef.current = null;
}
// 更新步骤状态为错误
const errorSteps = [...processingSteps];
for (let i = 0; i < errorSteps.length; i++) {
if (errorSteps[i].status === "active") {
errorSteps[i].status = "error";
errorSteps[i].description = `处理失败: ${error instanceof Error ? error.message : '未知错误'}`;
}
}
setProcessingSteps(errorSteps);
// 重置处理状态
setUploadStage("idle");
// 显示错误消息
messageService.error(`文件处理失败:${error instanceof Error ? error.message : '未知错误'}`, {
title: '处理失败',
confirmText: '确定',
cancelText: '',
});
// 抛出错误,让React错误边界捕获
throw error;
}
};
// 检查文件处理状态
const checkProcessingStatus = async (fileIds: number[]) => {
try {
// console.log('【调试-checkProcessingStatus】检查文件处理状态:', fileIds);
// 检查组件是否已卸载
if (!isMountedRef.current) {
console.error('【调试-checkProcessingStatus】组件已卸载,取消检查');
return;
}
// 如果没有文件ID,不执行检查
if (!fileIds.length) {
// console.log('【调试-checkProcessingStatus】没有需要检查的文件');
return;
}
// 获取文件状态
// console.log('【调试-checkProcessingStatus】发送请求获取文件状态');
const response = await getDocumentsStatus(fileIds, undefined, loaderData.frontendJWT || undefined);
if (response.error) {
console.error('【调试-checkProcessingStatus】获取文件状态出错:', response.error);
return;
}
// console.log('【调试-checkProcessingStatus】文件状态响应:', response.data);
if (!response.data || !response.data.length) {
// console.log('【调试-checkProcessingStatus】没有返回文件状态数据');
return;
}
// 检查是否所有文件都已完成处理
const allCompleted = response.data.every(doc => doc.status === DocumentStatus.PROCESSED);
// console.log('【调试-checkProcessingStatus】文件处理状态:', { allCompleted, statusList: response.data.map(doc => doc.status) });
// 更新步骤状态
if (allCompleted) {
// console.log('【调试-checkProcessingStatus】所有文件处理完成,更新步骤状态为完成');
// 清除文件处理进度定时器
if (processingStatusIntervalRef.current) {
clearInterval(processingStatusIntervalRef.current);
processingStatusIntervalRef.current = null;
// console.log('【调试-checkProcessingStatus】文件处理完成,清除文件处理进度定时器');
}
// 更新为全部完成状态
const completedSteps = processingSteps.map(step => ({
...step,
status: "done" as Step["status"]
}));
completedSteps[0].description = "文件已成功上传到服务器";
completedSteps[1].description = "已进入处理队列";
completedSteps[2].description = "文档格式转换完成,内容已拆分";
completedSteps[3].description = "评查点已成功抽取";
completedSteps[4].description = "文档评查已完成";
completedSteps[5].description = "文档已准备就绪,可以查看";
setProcessingSteps(completedSteps);
setUploadStage("completed");
} else {
// 根据当前状态更新步骤
const currentStatus = response.data[0].status;
// console.log('【调试-checkProcessingStatus】根据当前状态更新步骤:', currentStatus);
updateProcessingSteps(currentStatus);
}
// 刷新队列中的文件状态
updateQueueFilesStatus(response.data);
} catch (error) {
console.error('【调试-checkProcessingStatus】检查文件处理状态出错:', error);
// 这里不抛出错误,让定时器继续运行
}
};
// 更新处理步骤状态
// 步骤索引: 0-文件上传, 1-排队等待, 2-文档转换拆分, 3-评查点抽取, 4-评查点审核, 5-审核准备
const updateProcessingSteps = (status: DocumentStatus) => {
// console.log('更新处理步骤状态:', status);
const updatedSteps = [...processingSteps];
// 重置所有步骤为等待状态
updatedSteps.forEach(step => {
step.status = "waiting";
});
// 第一步始终是完成的(文件上传)
updatedSteps[0].status = "done";
updatedSteps[0].description = "文件已成功上传到服务器";
// 根据状态更新步骤
switch (status) {
case DocumentStatus.QUEUED:
// 排队等待中
updatedSteps[1].status = "active";
updatedSteps[1].description = "文档正在排队等待处理...";
break;
case DocumentStatus.CUTTING:
// 排队完成,开始转换
updatedSteps[1].status = "done";
updatedSteps[1].description = "已进入处理队列";
updatedSteps[2].status = "active";
updatedSteps[2].description = "正在转换文档格式,拆分文档内容...";
break;
case DocumentStatus.EXTRACTIONING:
updatedSteps[1].status = "done";
updatedSteps[1].description = "已进入处理队列";
updatedSteps[2].status = "done";
updatedSteps[2].description = "文档格式转换完成,内容已拆分";
updatedSteps[3].status = "active";
updatedSteps[3].description = "正在抽取评查点...";
break;
case DocumentStatus.EVALUATIONING:
updatedSteps[1].status = "done";
updatedSteps[1].description = "已进入处理队列";
updatedSteps[2].status = "done";
updatedSteps[2].description = "文档格式转换完成,内容已拆分";
updatedSteps[3].status = "done";
updatedSteps[3].description = "评查点已成功抽取";
updatedSteps[4].status = "active";
updatedSteps[4].description = "正在评查文档...";
break;
case DocumentStatus.PROCESSED:
updatedSteps[1].status = "done";
updatedSteps[1].description = "已进入处理队列";
updatedSteps[2].status = "done";
updatedSteps[2].description = "文档格式转换完成,内容已拆分";
updatedSteps[3].status = "done";
updatedSteps[3].description = "评查点已成功抽取";
updatedSteps[4].status = "done";
updatedSteps[4].description = "文档评查已完成";
updatedSteps[5].status = "done";
updatedSteps[5].description = "文档已准备就绪,可以查看";
break;
}
setProcessingSteps(updatedSteps);
};
// 更新队列中文件的状态
const updateQueueFilesStatus = (updatedDocs: Document[]) => {
if (!updatedDocs.length) return;
// console.log('更新队列中文件状态:', updatedDocs);
setQueueFiles(prevFiles => {
// 创建文件ID到状态的映射
const statusMap = new Map(updatedDocs.map(doc => [doc.id, doc.status]));
// 更新队列中的文件状态
return prevFiles.map(file => {
if (statusMap.has(file.id)) {
return { ...file, status: statusMap.get(file.id)! };
}
return file;
});
});
};
// 重置上传状态 - 不清除队列状态检查定时器
const resetUpload = () => {
// 清除上传和处理相关的定时器
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
uploadProgressIntervalRef.current = null;
}
if (processingStatusIntervalRef.current) {
clearInterval(processingStatusIntervalRef.current);
processingStatusIntervalRef.current = null;
}
// 重置状态
setUploadStage("idle");
setUploadProgress(0);
setUploadSpeed("0KB/s");
setProcessingSteps(steps => steps.map(step => ({ ...step, status: "waiting" })));
setCurrentFiles([]);
setCompletedFiles([]);
// 重置合同文件状态
setContractMainFiles([]);
setContractAttachmentFiles([]);
// 重置步骤状态
setProcessingSteps([
{ title: "文件上传", description: "等待上传文件到服务器...", status: "waiting" },
{ title: "排队等待", description: "等待进入处理队列", status: "waiting" },
{ title: "文档转换拆分", description: "转换文档格式,拆分文档内容", status: "waiting" },
{ title: "评查点抽取", description: "DeepSeek 抽取中", status: "waiting" },
{ title: "评查点审核", description: "DeepSeek 评查中", status: "waiting" },
{ title: "审核准备", description: "文档已准备就绪,等待审核", status: "waiting" }
]);
// 重置上传区域
if (uploadAreaRef.current) {
uploadAreaRef.current.resetFileInput();
}
if (contractMainFileRef.current) {
contractMainFileRef.current.resetFileInput();
}
if (contractAttachmentFileRef.current) {
contractAttachmentFileRef.current.resetFileInput();
}
};
// 获取当前时间字符串
const getCurrentTime = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
// 格式化文件大小显示
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];
};
// 获取文档类型名称
const getDocumentTypeName = (codeId: number) => {
const type = documentTypesState.find(t => t.id === codeId);
return type ? type.name : '未知类型';
};
// 处理查看文件
const handleViewFile = async (record: Document) => {
try {
// console.log('【调试-handleViewFile】开始处理查看文件,文件ID:', record.id);
// console.log('【调试-handleViewFile】开始处理查看文件,文件:', record);
// 点击查看
// 检查audit_status是否为0,如果是则更新为2
if (record.audit_status === 0 || record.audit_status === null) {
try {
// 从loader data中获取用户ID
const userId = loaderData.userInfo?.user_id?.toString();
if (!userId) {
toastService.error('用户身份验证失败');
return;
}
// console.log('【调试-handleViewFile】更新文件审核状态,文件ID:', record.id);
const response = await updateDocumentAuditStatus(record.id.toString(), 2, userId);
if (response.error) {
console.error('【调试-handleViewFile】更新文件审核状态失败:', response.error);
toastService.error('更新文件审核状态失败:' + (response.error || '未知错误'));
} else {
// console.log('【调试-handleViewFile】更新文件审核状态成功');
}
} catch (error) {
console.error('【调试-handleViewFile】更新文件审核状态时出错:', error);
toastService.error('更新文件审核状态时出错:' + (error instanceof Error ? error.message : '未知错误'));
// 即使更新失败,也继续导航
}
}
// console.log(`【调试-handleViewFile】准备导航到文件详情页,文件ID: ${record.id}`);
// 检查组件是否已卸载
if (!isMountedRef.current) {
console.error('【调试-handleViewFile】组件已卸载,取消导航');
return;
}
// 使用 setTimeout 延迟导航,确保状态已更新
setTimeout(() => {
try {
if (isMountedRef.current) {
// console.log(`【调试-handleViewFile】执行导航,URL: /reviews?id=${record.id}&previousRoute=filesUpload`);
navigate(`/reviews?id=${record.id}&previousRoute=filesUpload`);
} else {
console.error('【调试-handleViewFile】组件已卸载,取消延迟导航');
}
} catch (navError) {
console.error('【调试-handleViewFile】导航执行错误:', navError);
// 不抛出异常,防止组件崩溃
}
}, 0);
} catch (outerError) {
console.error('【调试-handleViewFile】查看文件处理过程中发生错误:', outerError);
toastService.error('查看文件失败:' + (outerError instanceof Error ? outerError.message : '未知错误'));
// 不抛出异常,防止组件崩溃
}
};
// 表格列定义
const columns = [
{
title: "文件名",
key: "name",
width: "40%",
render: (_: unknown, record: Document) => (
<div className="flex items-center">
<i className={`${record.name?.includes('.pdf') ? 'ri-file-pdf-line text-red-500' : 'ri-file-word-2-line text-blue-500'} mr-2 text-lg`}></i>
<span className="truncate">{record.name || '未知文件'}</span>
</div>
)
},
{
title: "文件类型",
key: "type_id",
width: "15%",
render: (_: unknown, record: Document) => {
const typeName = getDocumentTypeName(record.type_id);
// 根据typeName判断应用哪种样式类名
let typeClass = "file-type-badge";
if (typeName.includes('合同')) {
typeClass += " file-type-tag-contract";
} else if (typeName.includes('许可') || typeName.includes('行政许可')) {
typeClass += " file-type-tag-license-doc";
} else if (typeName.includes('处罚') || typeName.includes('行政处罚')) {
typeClass += " file-type-tag-punishment-doc";
} else {
typeClass += " file-type-tag-other";
}
return (
<span className={typeClass}>
{typeName}
</span>
);
}
},
{
title: "大小",
key: "file_size",
width: "15%",
render: (_: unknown, record: Document) => formatFileSize(record.file_size)
},
{
title: "状态",
key: "status",
width: "15%",
render: (_: unknown, record: Document) => {
let statusClass = "";
let statusIcon = "";
let statusText = "";
switch(record.status) {
case 'waiting':
statusClass = "status-processing";
statusIcon = "ri-loader-4-line";
statusText = "等待中";
break;
case DocumentStatus.WAITING:
statusClass = "status-processing";
statusIcon = "ri-loader-4-line";
statusText = "等待中";
break;
case DocumentStatus.CUTTING:
statusClass = "status-processing";
statusIcon = "ri-loader-4-line";
statusText = "切分中";
break;
case DocumentStatus.EXTRACTIONING:
statusClass = "status-processing";
statusIcon = "ri-loader-4-line";
statusText = "抽取中";
break;
case DocumentStatus.EVALUATIONING:
statusClass = "status-processing";
statusIcon = "ri-loader-4-line";
statusText = "评查中";
break;
case DocumentStatus.FAILED:
statusClass = "status-error";
statusIcon = "ri-close-circle-line";
statusText = "抽取异常";
break;
case DocumentStatus.PROCESSED:
statusClass = "status-success";
statusIcon = "ri-checkbox-circle-line";
statusText = "已完成";
break;
}
return (
<span className={`status-badge ${statusClass}`}>
<i className={`${statusIcon} mr-1`}></i>
{statusText}
</span>
);
}
},
{
title: "操作",
key: "operation",
width: "25%",
render: (_: unknown, record: Document) => (
<div className="flex flex-wrap gap-1">
<Button
type="default"
size="small"
disabled={record.status !== DocumentStatus.PROCESSED}
icon="ri-eye-line"
onClick={() => handleViewFile(record)}
className="text-xs px-2 py-1 h-7"
>
</Button>
{record.type_id === 1 && record.status === DocumentStatus.PROCESSED && (
<>
<Button
type="primary"
size="small"
icon="ri-attachment-line"
onClick={() => {
setSelectedDocumentId(record.id);
setSelectedDocumentName(record.name)
setShowAttachmentUpload(true);
}}
className="text-xs px-2 py-1 h-7"
>
</Button>
<Button
type="default"
size="small"
icon="ri-file-copy-line"
onClick={() => {
setSelectedDocumentId(record.id);
setSelectedDocumentName(record.name)
setShowTemplateUpload(true);
}}
className="text-xs px-2 py-1 h-7"
>
</Button>
</>
)}
</div>
)
}
];
// 返回上一级防抖处理
const handleBackClick = () =>{
if(isNavigating) return;
setIsNavigating(true)
navigate(-1)
revalidator.revalidate()
}
return (
<div className="file-upload-page">
{/* 页面头部 */}
<div className="page-header">
<div className="flex items-center gap-4">
<h2 className="page-title"></h2>
{/* 全局队列状态显示 */}
{globalQueueStatus && (
<div className="flex items-center gap-3 text-sm">
<span className="flex items-center gap-1 text-orange-600">
<i className="ri-time-line"></i>
: {globalQueueStatus.documents.waiting}
</span>
<span className="flex items-center gap-1 text-blue-600">
<i className="ri-loader-4-line"></i>
: {globalQueueStatus.documents.processing}
</span>
</div>
)}
</div>
<button className="ant-btn ant-btn-default flex items-center my-2" onClick={()=>handleBackClick()}><i className="ri-arrow-left-line"></i>{isNavigating ? '返回中...' : '返回'}</button>
</div>
{/* 文件类型选择和上传表单 */}
<Form method="post" encType="multipart/form-data" ref={formRef}>
{/* 文件类型选择 */}
<Card title={<h3></h3>} className="mb-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="form-group">
<label htmlFor="file-type-select" className="form-label"> <span className="text-red-500">*</span></label>
<select
id="file-type-select"
name="fileType"
className={`form-select ${fileTypeError ? 'border-red-500' : ''}`}
value={fileType}
onChange={handleFileTypeChange}
disabled={uploadStage !== "idle"}
>
<option value=""></option>
{documentTypesState.map(type => (
<option key={type.id} value={type.id}>{type.name}</option>
))}
{/* <option key="TSXZCF" value="TSXZCF">测试行政处罚卷宗</option> */}
</select>
{fileTypeError && (
<div className="text-red-500 text-sm mt-1">{fileTypeError}</div>
)}
<div className="form-tip"></div>
</div>
<div className="form-group">
<label htmlFor="priority-select" className="form-label"></label>
<select
id="priority-select"
name="priority"
className="form-select"
value={priority}
onChange={(e) => setPriority(e.target.value as Priority)}
disabled={uploadStage !== "idle"}
>
<option value={Priority.NORMAL}>{PRIORITY_LABELS[Priority.NORMAL]}</option>
<option value={Priority.HIGH}>{PRIORITY_LABELS[Priority.HIGH]}</option>
<option value={Priority.URGENT}>{PRIORITY_LABELS[Priority.URGENT]}</option>
</select>
<div className="form-tip"></div>
</div>
{/* 子类型(专属类型)- 始终显示 */}
<div className="form-group">
<label htmlFor="attribute-type-select" className="form-label">
<span className="required">*</span>
</label>
<select
id="attribute-type-select"
name="attributeType"
className="form-select"
value={attributeType}
onChange={(e) => setAttributeType(e.target.value)}
disabled={uploadStage !== "idle"}
required
>
{CONTRACT_TYPES.map(type => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
<div className="form-tip">//</div>
</div>
<div className="form-group">
<label htmlFor="docNumber" className="form-label"></label>
<input
type="text"
id="docNumber"
name="docNumber"
className="form-input w-full"
placeholder="请输入卷宗编号、合同编号等"
value={documentNumber}
onChange={(e) => setDocumentNumber(e.target.value)}
disabled={uploadStage !== "idle"}
/>
<div className="form-tip"></div>
</div>
</div>
<div className="grid grid-cols-1 mt-4">
<div className="form-group">
<label className="form-label" htmlFor="docRemark">
</label>
<textarea
id="docRemark"
name="docRemark"
className="form-textarea w-full"
placeholder="可输入文档的相关描述或备注信息"
rows={2}
value={remark}
onChange={(e) => setRemark(e.target.value)}
disabled={uploadStage !== "idle"}
></textarea>
</div>
</div>
</Card>
{/* 文件上传区域 */}
<Card className="mb-4">
{/* 自定义标题栏 */}
<div className="w-full flex justify-between items-center mb-4">
<h3 className="text-lg font-medium"></h3>
{isContractType && uploadStage === "idle" && (
<Button
type="primary"
icon="ri-upload-cloud-line"
onClick={() => checkAndPrepareUpload(contractMainFiles, contractAttachmentFiles, contractTemplateFiles)}
>
</Button>
)}
</div>
{/* 初始上传区域 */}
{uploadStage === "idle" && (
<>
{!isContractType ? (
// 标准上传区域 - 非合同类型
<UploadArea
ref={uploadAreaRef}
onFilesSelected={handleFilesSelected}
multiple={true}
accept=".pdf,.docx"
tipText="支持单个或多个文件上传,文件格式:PDF/Word"
shouldPreventFileSelect={!fileType}
/>
) : (
// 合同文件上传区域 - 三区域布局
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<div className="flex items-center gap-4 mb-2">
<h4 className="font-medium"></h4>
{contractMainFiles.length > 0 && (
<button
type="button"
onClick={() => {
setContractMainFiles([]);
if (contractMainFileRef.current) {
contractMainFileRef.current.resetFileInput();
}
}}
className="text-red-500 hover:text-red-700 transition-colors"
title="清空已选择的主文件"
>
<i className="ri-delete-bin-line"></i>
</button>
)}
</div>
<UploadArea
onFilesSelected={handleContractMainFilesSelected}
ref={contractMainFileRef}
multiple={false}
accept=".pdf,.docx"
tipText="请上传合同主文件,格式:.pdf/.docx"
mainText="上传合同主文件"
buttonText="选择主文件"
icon="ri-file-text-line"
shouldPreventFileSelect={!fileType}
/>
{contractMainFiles.length > 0 && (
<div className="mt-2 text-sm text-green-600">
<i className="ri-checkbox-circle-line"></i>
: <span className="font-medium">{contractMainFiles[0].name}</span>
</div>
)}
</div>
<div>
<div className="flex items-center gap-4 mb-2">
<h4 className="font-medium"></h4>
{contractAttachmentFiles.length > 0 && (
<button
type="button"
onClick={() => {
setContractAttachmentFiles([]);
if (contractAttachmentFileRef.current) {
contractAttachmentFileRef.current.resetFileInput();
}
}}
className="text-red-500 hover:text-red-700 transition-colors"
title="清空已选择的附件"
>
<i className="ri-delete-bin-line"></i>
</button>
)}
</div>
<UploadArea
onFilesSelected={handleContractAttachmentFilesSelected}
ref={contractAttachmentFileRef}
multiple={false}
accept=".pdf,.docx"
tipText="请上传合同附件,格式:.pdf/.docx"
mainText="上传合同附件"
buttonText="选择附件"
icon="ri-file-copy-line"
shouldPreventFileSelect={!fileType}
/>
{contractAttachmentFiles.length > 0 && (
<div className="mt-2 text-sm text-green-600">
<i className="ri-checkbox-circle-line"></i>
: {contractAttachmentFiles.map((file, index) => (
<span key={index} className="font-medium">{file.name}</span>
))}
</div>
)}
</div>
<div>
<div className="flex gap-4 items-center mb-2">
<h4 className="font-medium"></h4>
{contractTemplateFiles.length > 0 && (
<button
type="button"
onClick={() => {
setContractTemplateFiles([]);
}}
className="text-red-500 hover:text-red-700 transition-colors"
title="清空已选择的模板"
>
<i className="ri-delete-bin-line"></i>
</button>
)}
</div>
<UploadArea
onFilesSelected={handleContractTemplateFilesSelected}
multiple={false}
accept=".pdf,.docx"
tipText="请上传合同模板,格式:.pdf/.docx"
mainText="上传合同模板"
buttonText="选择模板"
icon="ri-file-copy-line"
shouldPreventFileSelect={!fileType}
/>
{contractTemplateFiles.length > 0 && (
<div className="mt-2 text-sm text-green-600">
<i className="ri-checkbox-circle-line"></i>
: <span className="font-medium">{contractTemplateFiles[0].name}</span>
</div>
)}
</div>
</div>
)}
{/* 测试文档标记 */}
<div className="switch-container mb-4">
<label className="switch" aria-label="标记为测试文档">
<input
type="checkbox"
checked={isTestDocument}
onChange={e => setIsTestDocument(e.target.checked)}
/>
<span className="slider"></span>
</label>
<span></span>
</div>
{/* 高级上传设置 */}
{ showAdvancedOptions && (
<div className="advanced-options">
<button
className={`advanced-options-toggle ${showAdvancedOptions ? 'open' : ''}`}
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
aria-expanded={showAdvancedOptions}
aria-controls="advanced-options-content"
type="button"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowAdvancedOptions(!showAdvancedOptions);
}
}}
>
<span></span>
<i className="ri-arrow-down-s-line" aria-hidden="true"></i>
</button>
<div
id="advanced-options-content"
className="advanced-options-content"
style={{ display: showAdvancedOptions ? 'block' : 'none' }}
aria-hidden={!showAdvancedOptions}
>
<div className="grid grid-cols-2 gap-4">
<div className="form-group">
<label className="form-label" htmlFor="storageType"></label>
<select
id="storageType"
name="storageType"
className="form-select w-full"
defaultValue="minio"
>
{STORAGE_TYPES.map(type => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
</select>
<div className="form-tip"></div>
</div>
<div className="form-group">
<label className="form-label" htmlFor="afterUpload"></label>
<select
id="afterUpload"
name="afterUpload"
className="form-select w-full"
defaultValue="list"
>
{AFTER_UPLOAD_OPTIONS.map(option => (
<option key={option.id} value={option.id}>
{option.name}
</option>
))}
</select>
<div className="form-tip"></div>
</div>
</div>
</div>
</div>
)}
</>
)}
{/* 上传进度显示 */}
{uploadStage !== "completed" && currentFiles.length > 0 && (
<FileProgress
fileName={`${currentFiles.length}个文件`}
fileSize={formatFileSize(currentFiles.reduce((sum, file) => sum + file.size, 0))}
progress={uploadProgress}
speed={''}
/>
)}
{/* 处理步骤显示 */}
{(uploadStage === "processing" || uploadStage === "completed") && (
<div className="mt-4 mb-4">
<ProcessingSteps steps={processingSteps} />
</div>
)}
{/* 文件信息显示 - 上传完成后显示 */}
{/* {uploadStage !== "idle" && completedFiles.length > 0 && ( */}
{uploadStage === "hadden" && completedFiles.length > 0 && (
<div className="file-info-grid">
<div>
<h4 className="font-medium mb-3"></h4>
<div className="space-y-4">
{completedFiles.map((file) => (
<div key={file.id} className="bg-white p-4 rounded-md border border-gray-200">
<ul className="file-info-list">
<li className="file-info-item">
<span className="file-info-label"></span>
<span className="file-info-value">{file.name}</span>
</li>
<li className="file-info-item">
<span className="file-info-label"></span>
<span className="file-info-value">{formatFileSize(file.size)}</span>
</li>
<li className="file-info-item">
<span className="file-info-label"></span>
<span className="file-info-value">{file.uploadTime}</span>
</li>
<li className="file-info-item">
<span className="file-info-label"></span>
<span className="file-info-value">
{FILE_TYPE_LABELS[file.fileType] || getDocumentTypeName(parseInt(file.fileType))}
</span>
</li>
<li className="file-info-item">
<span className="file-info-label"></span>
<span className="file-info-value"></span>
</li>
</ul>
</div>
))}
</div>
</div>
<div>
<h4 className="font-medium mb-3"></h4>
<div className="bg-gray-50 p-3 rounded-md border border-gray-200">
<p className="text-gray-500 text-sm"></p>
</div>
</div>
</div>
)}
{/* 上传新文件按钮 - 上传完成后显示 */}
{uploadStage !== "idle" && (
<div className="mt-6">
<Button
type="default"
icon="ri-refresh-line"
onClick={() => {
// 清除所有定时器
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
}
if (processingStatusIntervalRef.current) {
clearInterval(processingStatusIntervalRef.current);
}
// 重置状态
resetUpload();
}}
>
</Button>
</div>
)}
{/* 处理完成后的成功提示和查看按钮 - 仅在全部处理完成时显示 */}
{uploadStage === "completed" && (
<div className="mt-4">
<div className="bg-green-50 p-4 rounded-md mb-4 border border-green-100">
<div className="flex items-center text-green-800 mb-2">
<i className="ri-checkbox-circle-line text-xl mr-2"></i>
<span className="font-medium"></span>
</div>
<p className="text-sm text-green-700"></p>
</div>
{/* <div className="flex justify-end">
<Button
type="primary"
icon="ri-file-search-line"
>
查看详情并审核
</Button>
</div> */}
</div>
)}
</Card>
</Form>
{/* 上传队列 */}
<Card
title={
<div className="flex justify-between items-center">
<h3></h3>
<span className="text-gray-500 text-sm"> {queueFiles.length > 0 ? queueFiles.length : 0} </span>
</div>
}
>
<Table
columns={columns}
dataSource={queueFiles}
rowKey="id"
emptyText="暂无上传文件"
/>
</Card>
{/* 附件追加模态框 */}
{showAttachmentUpload && (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]"
onClick={(e) => {
// 点击蒙层关闭模态框
if (e.target === e.currentTarget) {
setShowAttachmentUpload(false);
setSelectedDocumentId(null);
setSelectedDocumentName(null)
setAttachmentFiles([]);
setAttachmentRemark("");
setAttachmentMergeMode('overwrite');
}
}
}
>
<div className="bg-white rounded-lg p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold"></h3>
<button
onClick={() => {
setShowAttachmentUpload(false);
setSelectedDocumentId(null);
setSelectedDocumentName(null)
setAttachmentFiles([]);
setAttachmentRemark("");
}}
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">
{/* 目标文档ID: <span className="font-medium">{selectedDocumentId}</span> */}
: <span className="font-medium">{selectedDocumentName}</span>
</p>
<p className="text-xs text-gray-500 mt-1">
.pdf.docx.zip.rar格式<i className="ri-information-2-line mr-1"></i>ZIP/RAR内需要保证文件格式一致
</p>
</div>
{/* 文件上传区域 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<span className="text-red-500">*</span>
</label>
<UploadArea
onFilesSelected={handleAttachmentFilesSelected}
multiple={true}
accept=".pdf,.docx,.zip,.rar"
tipText="支持.pdf、.docx、.zip、.rar格式,可多选"
mainText="选择附件文件"
buttonText="选择文件"
icon="ri-attachment-line"
/>
{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>
{/* 合并模式选择 */}
<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
type="default"
onClick={() => {
setShowAttachmentUpload(false);
setSelectedDocumentId(null);
setSelectedDocumentName(null)
setAttachmentFiles([]);
setAttachmentRemark("");
}}
disabled={attachmentUploading}
>
</Button>
<Button
type="primary"
onClick={handleAttachmentUpload}
disabled={attachmentFiles.length === 0 || attachmentUploading}
icon={attachmentUploading ? "ri-loader-4-line" : "ri-upload-cloud-line"}
>
{attachmentUploading ? '上传中...' : '开始追加'}
</Button>
</div>
</div>
</div>
</div>
)}
{/* 合同模板上传模态框 */}
{showTemplateUpload && (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]"
onClick={(e) => {
// 点击蒙层关闭模态框
if (e.target === e.currentTarget) {
setShowTemplateUpload(false);
setSelectedDocumentId(null);
setSelectedDocumentName(null)
setTemplateFile(null);
}
}}
>
<div className="bg-white rounded-lg p-6 w-full max-w-lg mx-4">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold"></h3>
<button
onClick={() => {
setShowTemplateUpload(false);
setSelectedDocumentId(null);
setSelectedDocumentName(null)
setTemplateFile(null);
}}
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">
{/* 目标文档ID: <span className="font-medium">{selectedDocumentId}</span> */}
: <span className="font-medium">{selectedDocumentName}</span>
</p>
<p className="text-xs text-gray-500 mt-1">
.pdf.docx格式
</p>
</div>
{/* 文件上传区域 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<span className="text-red-500">*</span>
</label>
<UploadArea
onFilesSelected={handleTemplateFileSelected}
multiple={false}
accept=".pdf,.docx"
tipText="支持.pdf、.docx格式"
mainText="选择模板文件"
buttonText="选择文件"
icon="ri-file-copy-line"
/>
{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
type="default"
onClick={() => {
setShowTemplateUpload(false);
setSelectedDocumentId(null);
setSelectedDocumentName(null)
setTemplateFile(null);
}}
disabled={templateUploading}
>
</Button>
<Button
type="primary"
onClick={handleTemplateUpload}
disabled={!templateFile || templateUploading}
icon={templateUploading ? "ri-loader-4-line" : "ri-upload-cloud-line"}
>
{templateUploading ? '上传中...' : '开始上传'}
</Button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
export function ErrorBoundary({ error }: { error?: Error }) {
// 记录错误到控制台,以便开发时查看
console.error('文件上传组件错误:', error || '未知错误');
return (
<div className="error-container p-6">
<h1 className="text-xl font-bold text-red-500 mb-4"></h1>
<p className="mb-4"></p>
{/* 在开发环境中显示错误详情 */}
<div className="bg-gray-100 p-4 rounded mb-4 overflow-auto max-h-[300px]">
<h2 className="font-bold mb-2"></h2>
{error ? (
<>
<p className="text-red-600">{error.message}</p>
{error.stack && (
<pre className="text-xs mt-2 whitespace-pre-wrap">{error.stack}</pre>
)}
</>
) : (
<p className="text-red-600"> React </p>
)}
<div className="mt-4 p-2 bg-yellow-50 border border-yellow-200 rounded">
<p className="text-sm font-medium"></p>
<ul className="list-disc pl-5 mt-1 text-sm">
<li></li>
<li>API </li>
<li></li>
<li></li>
</ul>
</div>
</div>
<Button type="primary" to="/"></Button>
</div>
);
}