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

1459 lines
50 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 } 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,
type Document,
type DocumentType,
type FileUploadResponse,
DocumentStatus
} from "~/api/files/files-upload";
import { updateDocumentAuditStatus } from "~/api/evaluation_points/rules-files";
export function links() {
return [
{ rel: "stylesheet", href: uploadStyles }
];
}
// 面包屑导航
export const handle = {
breadcrumb: () => {
return '上传文件'
}
}
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]: "紧急"
};
// 优先级中文映射
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;
};
}
// 模拟上传文件到服务器的API
async function uploadFileToServer(
binaryData: ArrayBuffer,
fileName: string,
fileType: string,
documentType: FileType,
priority: Priority,
documentNumber: string | null,
remark: string | null,
isTestDocument: boolean
): Promise<FileUploadResponse> {
// 在实际应用中,这里会使用fetch或axios发送请求到后端API
console.log(`[API] 上传文件: ${fileName}, 大小: ${binaryData.byteLength} 字节`);
try {
// 使用封装的上传函数
const response = await uploadDocumentToServer(
binaryData,
fileName,
fileType,
documentType,
PRIORITY_TO_CHINESE[priority],
documentNumber,
remark,
isTestDocument
);
if (response.error) {
console.error('[API] 上传错误:', response.error);
return {
success: false,
error: response.error
};
}
// 确保返回有效的FileUploadResponse对象
if (response.data) {
return response.data;
}
// 如果没有数据,则返回错误
return {
success: false,
error: '上传失败,未获取到响应数据'
};
} catch (error) {
console.error('[API] 上传错误:', error);
return {
success: false,
error: error instanceof Error ? error.message : '上传失败'
};
}
}
// 定义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;
};
// 添加 loader 函数
export async function loader({ request }: LoaderFunctionArgs) {
try {
// console.log('loader: 开始加载数据...');
const url = new URL(request.url);
const mode = url.searchParams.get("mode") || "create";
// 并行加载文档和文档类型
const [documentsResponse, typesResponse] = await Promise.all([
getTodayDocuments(),
getDocumentTypes()
]);
// console.log('loader: 文档加载结果:', documentsResponse);
// console.log('loader: 文档类型加载结果:', typesResponse);
if (documentsResponse.error || typesResponse.error) {
throw new Error(documentsResponse.error || typesResponse.error);
}
return Response.json({
mode,
documents: documentsResponse.data || [],
documentTypes: typesResponse.data || []
});
} catch (error) {
console.error('loader: 加载数据失败:', error);
return Response.json({
documents: [],
documentTypes: []
});
}
}
// 文件上传页面组件
export default function FilesUpload() {
// 使用 useLoaderData 获取初始数据
const { documents, documentTypes } = 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 [isContractType, setIsContractType] = useState<boolean>(false);
const [contractMainFiles, setContractMainFiles] = useState<File[]>([]);
const [contractAttachmentFiles, setContractAttachmentFiles] = useState<File[]>([]);
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: "DeepSeek 抽取中", status: "waiting" },
{ title: "评查点审核", description: "DeepSeek 评查中", status: "waiting" },
{ title: "审核准备", description: "文档已准备就绪,等待审核", status: "waiting" }
]);
const navigate = useNavigate();
// 队列文件状态
const [queueFiles, setQueueFiles] = useState<Document[]>(documents);
const [documentTypesState] = useState<DocumentType[]>(documentTypes);
// 构建文件类型标签映射
useEffect(() => {
// 清空之前的映射
Object.keys(FILE_TYPE_LABELS).forEach(key => {
delete FILE_TYPE_LABELS[key];
});
// 使用从API获取的文档类型构建新的映射
documentTypes.forEach(type => {
FILE_TYPE_LABELS[type.id.toString()] = type.name;
});
}, [documentTypes]);
// 上传完成后的文件信息列表
const [completedFiles, setCompletedFiles] = useState<UploadedFile[]>([]);
// 计时器引用
const progressIntervalRef = useRef<NodeJS.Timeout | null>(null);
// 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]);
// 状态检查定时器引用
const statusCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
// useEffect 处理上传队列状态检查定时器 - 只在组件卸载时清除
useEffect(() => {
console.log('设置上传队列状态检查定时器');
// 设置定时器检查队列中文件的状态,初始先加载一次查询
checkQueueStatus();
statusCheckIntervalRef.current = setInterval(checkQueueStatus, 10000);
// 只在组件卸载时清除
return () => {
console.log('组件卸载,清除上传队列状态检查定时器');
if (statusCheckIntervalRef.current) {
clearInterval(statusCheckIntervalRef.current);
statusCheckIntervalRef.current = null;
}
};
}, []);
// 检查队列中未完成文档的状态
const checkQueueStatus = async () => {
try {
// console.log('开始检查队列状态,当前队列文件:', queueFiles);
// 获取所有未完成的文档ID
const incompleteIds = queueFiles
.filter(file => file.status !== DocumentStatus.PROCESSED && file.id)
.map(file => file.id);
console.log('未完成的文档ID:', incompleteIds);
if (incompleteIds.length === 0) {
console.log('没有未完成的文档,跳过状态检查');
return;
}
// 获取这些文档的最新状态
const statusResponse = await getDocumentsStatus(incompleteIds);
console.log('状态检查响应:', statusResponse);
if (statusResponse.data) {
// 更新队列中的文档状态
setQueueFiles(prevFiles => {
const updatedFiles = prevFiles.map(file => {
const updatedStatus = statusResponse.data.find(doc => doc.id === file.id);
if (updatedStatus) {
console.log(`文档 ${file.id} 状态更新: ${file.status} -> ${updatedStatus.status}`);
return { ...file, status: updatedStatus.status };
}
return file;
});
// console.log('更新后的队列文件:', updatedFiles);
return updatedFiles;
});
}
} catch (error) {
console.error('检查文档状态失败:', error);
}
};
// 处理文件选择
const handleFilesSelected = (files: FileList) => {
if (files.length > 0) {
// 验证文件类型,只允许PDF文件
const validFiles: File[] = [];
let hasInvalidFiles = false;
Array.from(files).forEach(file => {
if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
validFiles.push(file);
} else {
hasInvalidFiles = true;
}
});
if (hasInvalidFiles) {
// 显示错误提示
messageService.error('只能上传PDF格式的文件', {
title: '文件类型错误',
confirmText: '确定',
cancelText: '',
});
}
if (validFiles.length > 0) {
setCurrentFiles(validFiles);
if (fileType) {
startUpload(validFiles);
}
}
}
};
// 处理文件类型变化
const handleFileTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
// 确保只有选择了有效的文件类型才进行设置
if (value) {
setFileType(value as FileType);
// 立即清除错误状态
setFileTypeError(null);
// 检查是否选择了合同类型
const selectedType = documentTypes.find(t => t.id.toString() === value);
const isContract = !!(selectedType && selectedType.name.includes('合同'));
setIsContractType(isContract);
// 重置文件状态
setContractMainFiles([]);
setContractAttachmentFiles([]);
setCurrentFiles([]);
// 如果已经有选中的文件,且选择了文件类型,则开始上传
if (currentFiles.length > 0 && !isContract) {
startUpload(currentFiles);
}
} else {
setFileType("");
setIsContractType(false);
// 如果用户选择了空选项,显示错误信息
setFileTypeError("上传文件之前请选择文件类型");
}
};
// 处理合同主文件选择
const handleContractMainFilesSelected = (files: FileList) => {
if (files.length > 0) {
// 验证文件类型,只允许PDF文件
const validFiles: File[] = [];
let hasInvalidFiles = false;
Array.from(files).forEach(file => {
if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
validFiles.push(file);
} else {
hasInvalidFiles = true;
}
});
if (hasInvalidFiles) {
// 显示错误提示
messageService.error('只能上传PDF格式的文件', {
title: '文件类型错误',
confirmText: '确定',
cancelText: '',
});
}
if (validFiles.length > 0) {
setContractMainFiles(validFiles);
checkAndPrepareUpload(validFiles, contractAttachmentFiles);
}
}
};
// 处理合同附件选择
const handleContractAttachmentFilesSelected = (files: FileList) => {
if (files.length > 0) {
// 验证文件类型,只允许PDF文件
const validFiles: File[] = [];
let hasInvalidFiles = false;
Array.from(files).forEach(file => {
if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
validFiles.push(file);
} else {
hasInvalidFiles = true;
}
});
if (hasInvalidFiles) {
// 显示错误提示
messageService.error('只能上传PDF格式的文件', {
title: '文件类型错误',
confirmText: '确定',
cancelText: '',
});
}
if (validFiles.length > 0) {
setContractAttachmentFiles(validFiles);
checkAndPrepareUpload(contractMainFiles, validFiles);
}
}
};
// 检查并准备上传
const checkAndPrepareUpload = (mainFiles: File[], attachmentFiles: File[]) => {
// 当两个区域都有文件时才准备上传
if (mainFiles.length > 0 && attachmentFiles.length > 0) {
// 合并所有文件
const allFiles = [...mainFiles, ...attachmentFiles];
// 这里的currentFiles的长度是上传进度条是否显示的关键
// setCurrentFiles(allFiles);
// 将准备上传的操作移到这里,暂时不执行
console.log('准备上传', allFiles.length, '个文件');
// startUpload(allFiles);
}
};
// 开始上传文件
const startUpload = async (files: File[]) => {
try {
// 再次验证所有文件类型,确保只有PDF文件
const invalidFiles = files.filter(file =>
file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')
);
if (invalidFiles.length > 0) {
throw new Error('只能上传PDF格式的文件');
}
setUploadStage("uploading");
setUploadProgress(0);
// 计算总文件大小
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
let uploadedSize = 0;
// 更新步骤状态
const updatedSteps = [...processingSteps];
updatedSteps[0].status = "active";
updatedSteps[0].description = `正在上传 ${files.length} 个文件到服务器...`;
setProcessingSteps(updatedSteps);
// 转换文件为二进制格式
console.log("开始转换文件到二进制格式...");
// 模拟上传进度
if (progressIntervalRef.current) {
clearInterval(progressIntervalRef.current);
}
const startTime = Date.now();
let lastUploadedSize = 0;
progressIntervalRef.current = setInterval(() => {
const currentTime = Date.now();
const timeElapsed = (currentTime - startTime) / 1000; // 转换为秒
const currentSpeed = (uploadedSize - lastUploadedSize) / timeElapsed; // 字节/秒
lastUploadedSize = uploadedSize;
// 更新上传速度显示
setUploadSpeed(`${formatFileSize(currentSpeed)}/s`);
// 更新进度
const progress = (uploadedSize / totalSize) * 100;
setUploadProgress(progress);
}, 1000);
// 上传所有文件
const uploadedFiles: UploadedFile[] = [];
for (const file of files) {
// 转换文件为二进制格式
const binaryData = await uploadFileToBinary(file);
// 上传文件
const response = await uploadFileToServer(
binaryData,
file.name,
file.type,
fileType as FileType,
priority,
documentNumber || null,
remark || null,
isTestDocument
);
if (!response.success || !response.result) {
throw new Error(response.error || "上传失败");
}
// 更新已上传大小
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
}
};
uploadedFiles.push(newFile);
}
// 清除进度定时器
if (progressIntervalRef.current) {
clearInterval(progressIntervalRef.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()
};
});
setQueueFiles(prev => [...newDocuments, ...prev]);
// 设置当前文件为已上传的文件
setCompletedFiles(uploadedFiles);
// 完成上传后开始处理流程
startProcessing(uploadedFiles);
} catch (error) {
console.error("文件上传错误:", error);
// 更新步骤状态为错误
const errorSteps = [...processingSteps];
errorSteps[0].status = "error";
errorSteps[0].description = `上传文件失败: ${error instanceof Error ? error.message : '未知错误'}`;
setProcessingSteps(errorSteps);
// 清除进度定时器
if (progressIntervalRef.current) {
clearInterval(progressIntervalRef.current);
}
// 显示错误提示
messageService.error(`文件上传失败:${error instanceof Error ? error.message : '未知错误'}`, {
title: '文件上传失败',
confirmText: '确定',
cancelText: '',
onConfirm: () => {
resetUpload();
}
});
resetUpload();
}
};
// 开始处理上传的文件
const startProcessing = (files: UploadedFile[]) => {
// 更新上传阶段
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('开始处理文件,设置文件处理进度定时器');
// 清除之前的进度定时器(如果存在)
if (progressIntervalRef.current) {
clearInterval(progressIntervalRef.current);
}
// 立即开始检查状态
checkProcessingStatus(fileIds);
// 设置文件处理进度定时器,每10秒检查一次状态
progressIntervalRef.current = setInterval(() => {
console.log('文件处理进度定时器触发,检查文件状态');
checkProcessingStatus(fileIds);
}, 10000);
};
// 检查文件处理状态
const checkProcessingStatus = async (fileIds: number[]) => {
try {
console.log('检查文件处理状态:', fileIds);
// 如果没有文件ID,不执行检查
if (!fileIds.length) {
console.log('没有需要检查的文件');
return;
}
// 获取文件状态
const response = await getDocumentsStatus(fileIds);
if (response.error) {
console.error('获取文件状态出错:', response.error);
return;
}
console.log('文件状态响应:', response.data);
if (!response.data || !response.data.length) {
console.log('没有返回文件状态数据');
return;
}
// 检查是否所有文件都已完成处理
const allCompleted = response.data.every(doc => doc.status === DocumentStatus.PROCESSED);
// 更新步骤状态
if (allCompleted) {
console.log('所有文件处理完成,更新步骤状态为完成');
// 清除文件处理进度定时器
if (progressIntervalRef.current) {
clearInterval(progressIntervalRef.current);
progressIntervalRef.current = null;
console.log('文件处理完成,清除文件处理进度定时器');
}
// 更新为全部完成状态
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 = "文档已准备就绪,可以查看";
setProcessingSteps(completedSteps);
setUploadStage("completed");
} else {
// 根据当前状态更新步骤
updateProcessingSteps(response.data[0].status);
}
// 刷新队列中的文件状态
updateQueueFilesStatus(response.data);
} catch (error) {
console.error('检查文件处理状态出错:', error);
}
};
// 更新处理步骤状态
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.CUTTING:
updatedSteps[1].status = "active";
updatedSteps[1].description = "正在转换文档格式,拆分文档内容...";
break;
case DocumentStatus.EXTRACTIONING:
updatedSteps[1].status = "done";
updatedSteps[1].description = "文档格式转换完成,内容已拆分";
updatedSteps[2].status = "active";
updatedSteps[2].description = "正在抽取评查点...";
break;
case DocumentStatus.EVALUATIONING:
updatedSteps[1].status = "done";
updatedSteps[1].description = "文档格式转换完成,内容已拆分";
updatedSteps[2].status = "done";
updatedSteps[2].description = "评查点已成功抽取";
updatedSteps[3].status = "active";
updatedSteps[3].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 = "文档已准备就绪,可以查看";
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 (progressIntervalRef.current) {
clearInterval(progressIntervalRef.current);
progressIntervalRef.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: "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) => {
// 检查audit_status是否为0,如果是则更新为2
if (record.audit_status === 0 || record.audit_status === null) {
try {
const response = await updateDocumentAuditStatus(record.id.toString(), 2);
if (response.error) {
console.error('更新文件审核状态失败:', response.error);
toastService.error('更新文件审核状态失败:' + (response.error || '未知错误'));
}
} catch (error) {
console.error('更新文件审核状态时出错:', error);
toastService.error('更新文件审核状态时出错:' + (error instanceof Error ? error.message : '未知错误'));
}
}
navigate(`/reviews?id=${record.id}&previousRoute=filesUpload`);
};
// 表格列定义
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);
return (
<span className="file-type-badge">
{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: "15%",
render: (_: unknown, record: Document) => (
<Button
type="default"
size="small"
disabled={record.status !== DocumentStatus.PROCESSED}
icon="ri-eye-line"
onClick={() => handleViewFile(record)}
>
</Button>
)
}
];
return (
<div className="file-upload-page">
{/* 页面头部 */}
<div className="page-header">
<h2 className="page-title"></h2>
</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>
{documentTypes.map(type => (
<option key={type.id} value={type.id}>{type.name}</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="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>
{contractMainFiles.length > 0 && contractAttachmentFiles.length > 0 && (
<Button
type="primary"
icon="ri-upload-cloud-line"
onClick={() => startUpload([...contractMainFiles, ...contractAttachmentFiles])}
>
</Button>
)}
</div>
{/* 初始上传区域 */}
{uploadStage === "idle" && (
<>
{!isContractType ? (
// 标准上传区域 - 非合同类型
<UploadArea
ref={uploadAreaRef}
onFilesSelected={handleFilesSelected}
multiple={true}
accept=".pdf"
tipText="支持单个或多个pdf文件上传,文件格式:PDF"
shouldPreventFileSelect={!fileType}
/>
) : (
// 合同文件上传区域 - 双区域并排
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 className="font-medium mb-2"></h4>
<UploadArea
onFilesSelected={handleContractMainFilesSelected}
ref={contractMainFileRef}
multiple={false}
accept=".pdf"
tipText="请上传合同主文件,格式:PDF"
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>
<h4 className="font-medium mb-2"></h4>
<UploadArea
onFilesSelected={handleContractAttachmentFilesSelected}
ref={contractAttachmentFileRef}
multiple={false}
accept=".pdf"
tipText="请上传合同附件,格式:PDF"
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="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={uploadSpeed}
/>
)}
{/* 处理步骤显示 */}
{(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 (progressIntervalRef.current) {
clearInterval(progressIntervalRef.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>
</div>
);
}
export function ErrorBoundary() {
return (
<div className="error-container p-6">
<h1 className="text-xl font-bold text-red-500 mb-4"></h1>
<p className="mb-4"></p>
<Button type="primary" to="/"></Button>
</div>
);
}