import { useState, useEffect, useRef, useCallback } from "react"; import { MetaFunction, ActionFunctionArgs, json } from "@remix-run/node"; import { Form } 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"; export function links() { return [ { rel: "stylesheet", href: uploadStyles } ]; } export const meta: MetaFunction = () => { return [ { title: "待审核文件上传 - 中国烟草AI合同及卷宗审核系统" }, { name: "description", content: "上传待审核的合同文件、专卖许可证申请、行政处罚决定书等文档,进行AI智能审核" }, { name: "keywords", content: "文件上传,合同审核,专卖许可证,行政处罚,AI审核,中国烟草" } ]; }; // 文件类型定义 export enum FileType { CONTRACT = "1", LICENSE = "2", PUNISHMENT = "3", OTHER = "4" } // 文件类型标签映射 export const FILE_TYPE_LABELS: Record = { [FileType.CONTRACT]: "合同文档", [FileType.LICENSE]: "专卖许可证", [FileType.PUNISHMENT]: "行政处罚决定书", [FileType.OTHER]: "其他文档" }; // 优先级定义 export enum Priority { NORMAL = "normal", HIGH = "high", URGENT = "urgent" } // 优先级标签映射 export const PRIORITY_LABELS: Record = { [Priority.NORMAL]: "普通", [Priority.HIGH]: "优先", [Priority.URGENT]: "紧急" }; // 优先级中文映射 const PRIORITY_TO_CHINESE: Record = { [Priority.NORMAL]: "普通", [Priority.HIGH]: "优先", [Priority.URGENT]: "紧急" }; // 处理状态定义 export enum ProcessingStatus { WAITING = "waiting", PROCESSING = "processing", SUCCESS = "success", ERROR = "error" } // 上传的文件信息接口 export interface UploadedFile { id: string; name: string; size: number; type: string; fileType: FileType; priority: Priority; status: ProcessingStatus; uploadTime: string; processingInfo?: { progress: number; currentStep?: number; }; } // 文件上传响应接口 interface FileUploadResponse { success: boolean; fileId?: string; message?: string; error?: string; } // 将文件转换为二进制数据 async function uploadFileToBinary(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { if (reader.result instanceof ArrayBuffer) { resolve(reader.result); } else { reject(new Error('无法将文件转换为二进制格式')); } }; reader.onerror = () => { reject(new Error('读取文件失败')); }; // 读取文件为 ArrayBuffer (二进制格式) reader.readAsArrayBuffer(file); }); } // 模拟上传文件到服务器的API async function uploadFileToServer( binaryData: ArrayBuffer, fileName: string, fileType: string, documentType: FileType, priority: Priority ): Promise { // 在实际应用中,这里会使用fetch或axios发送请求到后端API console.log(`[模拟API] 上传文件: ${fileName}, 大小: ${binaryData.byteLength} 字节`); // 模拟网络延迟 await new Promise(resolve => setTimeout(resolve, 500)); try { // 创建FormData对象,将文件和其他信息一起提交 const formData = new FormData(); // 将二进制数据转换为Blob并添加到FormData const blob = new Blob([binaryData], { type: fileType }); formData.append('file', blob, fileName); // 将 type_id 和 priority 添加到一个JSON对象中 const uploadInfo = { type_id: Number(documentType), // 确保 type_id 是数字值 evaluation_level: PRIORITY_TO_CHINESE[priority] // 转换为中文优先级 }; // 添加 JSON 字符串到 FormData formData.append('upload_info', JSON.stringify(uploadInfo)); // 创建HTTP请求的参数 const requestParams = { method: 'POST', url: 'http://172.16.0.55:8000/admin/documents/upload', headers: { // FormData会自动设置Content-Type为multipart/form-data 'X-File-Name': encodeURIComponent(fileName) }, body: formData }; // 打印 FormData 内容的正确方式 console.log('[模拟API] 请求参数:', { url: requestParams.url, headers: requestParams.headers, uploadInfo, // 直接打印原始对象更有帮助 formDataEntries: Array.from(formData.entries()).map(([key, value]) => { if (!(value instanceof Blob)) { return { key, value }; } }), fileName }); // 实际API调用 - 在生产环境中实现 const response = await fetch(requestParams.url, { method: requestParams.method, headers: requestParams.headers, body: requestParams.body }); if (!response.ok) { // 获取更多错误信息 const errorText = await response.text(); console.error(`上传失败 (${response.status}): ${errorText}`); throw new Error(`上传失败: ${response.status} ${response.statusText}`); } const data = await response.json(); return data; } catch (error) { console.error('[模拟API] 上传错误:', error); return { success: false, error: error instanceof Error ? error.message : '上传失败' }; } } // 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; if (!fileType) { return json( { success: false, error: "请选择文件类型" }, { status: 400 } ); } if (!fileUpload) { return json( { success: false, error: "未找到上传的文件" }, { status: 400 } ); } // 获取文件信息 console.log(`接收到文件: ${fileUpload.name}, 大小: ${fileUpload.size}, 类型: ${fileUpload.type}`); // 注意: 在实际的Remix action中,我们无法直接处理文件内容 // 这里的代码仅用于模拟。在前端组件中,我们将实现实际的文件处理逻辑。 // 模拟文件上传成功响应 return json({ success: true, message: "文件上传请求已接收", fileId: `file_${Date.now()}`, fileType, priority: priority || Priority.NORMAL, }); } catch (error) { console.error("文件上传失败:", error); return json( { success: false, error: "文件上传失败,请重试" }, { status: 500 } ); } } // 上传队列中的文件列表模拟数据 const MOCK_QUEUE_FILES: UploadedFile[] = [ { id: "1", name: "烟草产品销售合同(2023版).pdf", size: 5.2 * 1024 * 1024, // 5.2MB type: "application/pdf", fileType: FileType.CONTRACT, priority: Priority.NORMAL, status: ProcessingStatus.PROCESSING, uploadTime: "2023-10-25 14:25:18", processingInfo: { progress: 60, currentStep: 2 } }, { id: "2", name: "专卖许可证申请表.docx", size: 2.8 * 1024 * 1024, // 2.8MB type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", fileType: FileType.LICENSE, priority: Priority.HIGH, status: ProcessingStatus.WAITING, uploadTime: "2023-10-25 14:28:45" }, { id: "3", name: "XX公司违规处罚决定书.pdf", size: 3.1 * 1024 * 1024, // 3.1MB type: "application/pdf", fileType: FileType.PUNISHMENT, priority: Priority.NORMAL, status: ProcessingStatus.SUCCESS, uploadTime: "2023-10-25 14:15:30" } ]; // 文件上传页面组件 export default function FilesUpload() { // 状态管理 const [fileType, setFileType] = useState(""); const [priority, setPriority] = useState(Priority.NORMAL); const [currentFile, setCurrentFile] = useState(null); const [uploadProgress, setUploadProgress] = useState(0); const [uploadSpeed, setUploadSpeed] = useState("0KB/s"); const [uploadStage, setUploadStage] = useState<"idle" | "uploading" | "processing" | "completed">("idle"); const [processingSteps, setProcessingSteps] = useState([ { title: "文件上传", description: "等待上传文件到服务器...", status: "waiting" }, { title: "文档转换拆分", description: "转换文档格式,拆分文档内容", status: "waiting" }, { title: "评查点抽取", description: "DeepSeek 抽取中", status: "waiting" }, { title: "评查点审核", description: "DeepSeek 评查中", status: "waiting" }, { title: "审核准备", description: "文档已准备就绪,等待审核", status: "waiting" } ]); // 队列文件状态 const [queueFiles, setQueueFiles] = useState(MOCK_QUEUE_FILES); // 上传完成后的文件信息 const [completedFile, setCompletedFile] = useState(null); // 计时器引用 const progressIntervalRef = useRef(null); const processingIntervalRef = useRef(null); // UploadArea组件引用 const uploadAreaRef = useRef(null); // 表单提交引用 const formRef = useRef(null); // 清理定时器 useEffect(() => { return () => { if (progressIntervalRef.current) { clearInterval(progressIntervalRef.current); } if (processingIntervalRef.current) { clearInterval(processingIntervalRef.current); } }; }, []); // 处理表单提交 const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!currentFile || !fileType) { alert('请选择文件和文件类型'); return; } try { // 设置上传状态 setUploadStage('uploading'); // 实际上传 - 通过我们自定义的二进制上传方法 await startUpload(currentFile); } catch (error) { console.error('提交表单错误:', error); alert(`提交表单失败: ${error instanceof Error ? error.message : '未知错误'}`); } }; // 处理文件选择 const handleFilesSelected = useCallback((selectedFiles: FileList) => { console.log("selectedFiles", selectedFiles); if (selectedFiles.length === 0) return; if (!fileType) { alert("请先选择文件类型"); // 重置文件输入框 if (uploadAreaRef.current) { uploadAreaRef.current.resetFileInput(); } return; } setCurrentFile(selectedFiles[0]); // 立即开始上传 startUpload(selectedFiles[0]); }, [fileType]); // 开始上传文件 const startUpload = async (file: File) => { try { setUploadStage("uploading"); setUploadProgress(0); // 更新步骤状态 const updatedSteps = [...processingSteps]; updatedSteps[0].status = "active"; updatedSteps[0].description = `正在上传文件"${file.name}"到服务器...`; setProcessingSteps(updatedSteps); // 转换文件为二进制格式 console.log("开始转换文件到二进制格式..."); // 模拟上传进度 if (progressIntervalRef.current) { clearInterval(progressIntervalRef.current); } progressIntervalRef.current = setInterval(() => { setUploadProgress(prev => { const newProgress = prev + 2; // 根据文件大小调整上传速度 const speedFactor = Math.min(file.size / (1024 * 1024) + 1, 5); setUploadSpeed(`${Math.floor(Math.random() * 100 * speedFactor) + 50}KB/s`); if (newProgress >= 50) { // 只模拟到50%,剩下的50%留给实际上传 if (progressIntervalRef.current) { clearInterval(progressIntervalRef.current); } return 50; } return newProgress; }); }, 100); // 实际执行二进制转换 const binaryData = await uploadFileToBinary(file); console.log(`文件转换为二进制完成,大小: ${binaryData.byteLength} 字节`); // 模拟实际上传 setUploadProgress(60); // 转换完成,进度到60% setUploadSpeed(`${Math.floor(Math.random() * 200) + 100}KB/s`); console.log("开始上传文件到服务器..."); const response = await uploadFileToServer( binaryData, file.name, file.type, fileType as FileType, priority ); if (!response.success) { throw new Error(response.error || "上传失败"); } console.log("文件上传成功:", response); setUploadProgress(100); setUploadSpeed("完成"); // 创建新的文件对象并添加到队列 const newFile: UploadedFile = { id: `file_${Date.now()}`, name: file.name, size: file.size, type: file.type, fileType: fileType as FileType, priority, status: ProcessingStatus.PROCESSING, uploadTime: getCurrentTime(), processingInfo: { progress: 0, currentStep: 0 } }; // 添加到队列中 setQueueFiles(prev => [newFile, ...prev]); // 完成上传后开始处理流程 startProcessing(file); } catch (error) { console.error("文件上传错误:", error); // 更新步骤状态为错误 const errorSteps = [...processingSteps]; errorSteps[0].status = "error"; errorSteps[0].description = `上传文件"${file.name}"失败: ${error instanceof Error ? error.message : '未知错误'}`; setProcessingSteps(errorSteps); // 清除进度定时器 if (progressIntervalRef.current) { clearInterval(progressIntervalRef.current); } alert(`文件上传失败: ${error instanceof Error ? error.message : '未知错误'}`); resetUpload(); } }; // 开始处理文件 const startProcessing = (file: File) => { setUploadStage("processing"); // 更新步骤状态 - 将第一步标记为完成 const updatedSteps = [...processingSteps]; updatedSteps[0].status = "done"; updatedSteps[0].description = "文件上传已完成"; setProcessingSteps(updatedSteps); let currentStepIndex = 1; if (processingIntervalRef.current) { clearInterval(processingIntervalRef.current); } processingIntervalRef.current = setInterval(() => { if (currentStepIndex >= processingSteps.length) { if (processingIntervalRef.current) { clearInterval(processingIntervalRef.current); completeProcessing(file); } return; } // 更新当前步骤为活动状态 const updatedSteps = [...processingSteps]; updatedSteps[currentStepIndex].status = "active"; setProcessingSteps(updatedSteps); // 2.5秒后标记当前步骤为完成,进入下一步骤 setTimeout(() => { const nextUpdatedSteps = [...processingSteps]; nextUpdatedSteps[currentStepIndex].status = "done"; // 更新完成状态的描述 switch(currentStepIndex) { case 1: nextUpdatedSteps[currentStepIndex].description = "转换文档格式并拆分文档完成"; break; case 2: nextUpdatedSteps[currentStepIndex].description = "DeepSeek 抽取已完成"; break; case 3: nextUpdatedSteps[currentStepIndex].description = "DeepSeek 评查已完成"; break; case 4: nextUpdatedSteps[currentStepIndex].description = "审核准备已就绪"; break; } setProcessingSteps(nextUpdatedSteps); currentStepIndex++; }, 2000); }, 2500); }; // 完成处理流程 const completeProcessing = (file: File) => { // 设置当前状态为已完成 setUploadStage("completed"); // 创建完成的文件对象 if (file) { console.log("创建完成的文件对象..."); const newFile: UploadedFile = { id: `file_${Date.now()}`, name: file.name, size: file.size, type: file.type, fileType: fileType as FileType, priority, status: ProcessingStatus.SUCCESS, uploadTime: getCurrentTime() }; setCompletedFile(newFile); console.log("完成文件设置:", newFile); // 添加到队列中 setQueueFiles(prev => [newFile, ...prev]); } else { console.log("没有当前文件"); } }; // 重置上传状态 const resetUpload = () => { setUploadStage("idle"); setUploadProgress(0); setUploadSpeed("0KB/s"); setCurrentFile(null); setCompletedFile(null); // 重置步骤状态 const resetSteps = processingSteps.map(step => ({ ...step, status: "waiting" as Step["status"] })); resetSteps[0].description = "等待上传文件到服务器..."; resetSteps[1].description = "转换文档格式,拆分文档内容"; resetSteps[2].description = "DeepSeek 抽取中"; resetSteps[3].description = "DeepSeek 评查中"; resetSteps[4].description = "文档已准备就绪,等待审核"; setProcessingSteps(resetSteps); }; // 获取当前时间字符串 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 columns = [ { title: "文件名", key: "name", width: "40%", render: (_: unknown, record: UploadedFile) => (
{record.name}
) }, { title: "文件类型", key: "fileType", width: "15%", render: (_: unknown, record: UploadedFile) => { let typeClass = ""; let typeIcon = ""; switch(record.fileType) { case FileType.CONTRACT: typeClass = "file-type-contract"; typeIcon = "ri-file-list-3-line"; break; case FileType.LICENSE: typeClass = "file-type-license"; typeIcon = "ri-vip-crown-line"; break; case FileType.PUNISHMENT: typeClass = "file-type-punishment"; typeIcon = "ri-scales-line"; break; default: typeClass = "file-type-contract"; typeIcon = "ri-file-list-3-line"; } return ( {FILE_TYPE_LABELS[record.fileType]} ); } }, { title: "大小", key: "size", width: "15%", render: (_: unknown, record: UploadedFile) => formatFileSize(record.size) }, { title: "状态", key: "status", width: "15%", render: (_: unknown, record: UploadedFile) => { let statusClass = ""; let statusIcon = ""; let statusText = ""; switch(record.status) { case ProcessingStatus.WAITING: statusClass = "status-waiting"; statusIcon = "ri-time-line"; statusText = "等待中"; break; case ProcessingStatus.PROCESSING: statusClass = "status-processing"; statusIcon = "ri-loader-4-line"; statusText = "解析中"; break; case ProcessingStatus.SUCCESS: statusClass = "status-success"; statusIcon = "ri-checkbox-circle-line"; statusText = "已完成"; break; case ProcessingStatus.ERROR: statusClass = "status-error"; statusIcon = "ri-error-warning-line"; statusText = "失败"; break; } return ( {statusText} ); } }, { title: "操作", key: "operation", width: "15%", render: (_: unknown, record: UploadedFile) => ( ) } ]; return (
{/* 页面头部 */}

待审核文件上传

{/* 文件类型选择和上传表单 */}
{/* 文件类型选择 */} 选择文件类型} className="mb-4">
不同类型的文档将应用不同的审核规则
优先级影响文档在队列中的处理顺序
{/* 文件上传区域 */} 文件上传} className="mb-4"> {/* 初始上传区域 */} {uploadStage === "idle" && ( <> )} {/* 上传进度显示 */} {uploadStage !== "completed" && currentFile && ( )} {/* 处理步骤显示 */} {(uploadStage === "processing" || uploadStage === "completed") && (
)} {/* 完成后的文件信息 */} {uploadStage === "completed" && completedFile && (
评查成功

文件已成功上传并评查完成,请查看结果

文件信息

  • 文件名: {completedFile.name}
  • 文件大小: {formatFileSize(completedFile.size)}
  • 上传时间: {completedFile.uploadTime}
  • 文件类型: {FILE_TYPE_LABELS[completedFile.fileType]}
  • 审核规则: 系统自动选择

解析结果预览

合同编号: XS-2023-1025-001
合同名称: 烟草制品销售合同
签约日期: 2023年10月20日
合同金额: ¥ 1,580,000.00
当事人: 甲方:XX烟草公司,乙方:YY贸易有限公司
)}
{/* 上传队列 */}

上传队列

共 {queueFiles.length} 个文件
} > ); } export function ErrorBoundary() { return (

出错了

文件上传页面加载失败。请刷新页面或联系系统管理员。

); }