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

697 lines
23 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, useCallback } from "react";
import { MetaFunction, ActionFunctionArgs } from "@remix-run/node";
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 = "contract",
LICENSE = "license",
PUNISHMENT = "punishment",
OTHER = "other"
}
// 文件类型标签映射
export const FILE_TYPE_LABELS: Record<FileType, string> = {
[FileType.CONTRACT]: "合同文档",
[FileType.LICENSE]: "专卖许可证",
[FileType.PUNISHMENT]: "行政处罚决定书",
[FileType.OTHER]: "其他文档"
};
// 优先级定义
export enum Priority {
NORMAL = "normal",
HIGH = "high",
URGENT = "urgent"
}
// 优先级标签映射
export const PRIORITY_LABELS: Record<Priority, string> = {
[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;
};
}
// action处理文件上传请求
export async function action({ request }: ActionFunctionArgs) {
try {
const formData = await request.formData();
// 由于无法直接从Remix的action中处理文件上传,
// 实际环境中应使用FormData将文件发送到后端API
// 这里我们模拟处理过程,创建一个响应对象
const fileType = formData.get("fileType") as FileType;
const priority = formData.get("priority") as Priority;
if (!fileType) {
return Response.json(
{ success: false, error: "请选择文件类型" },
{ status: 400 }
);
}
// 模拟文件上传成功响应
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 }
);
}
}
// 上传队列中的文件列表模拟数据
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<FileType | "">("");
const [priority, setPriority] = useState<Priority>(Priority.NORMAL);
const [currentFile, setCurrentFile] = useState<File | null>(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<Step[]>([
{ 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<UploadedFile[]>(MOCK_QUEUE_FILES);
// 上传完成后的文件信息
const [completedFile, setCompletedFile] = useState<UploadedFile | null>(null);
// 计时器引用
const progressIntervalRef = useRef<NodeJS.Timeout | null>(null);
const processingIntervalRef = useRef<NodeJS.Timeout | null>(null);
// UploadArea组件引用
const uploadAreaRef = useRef<UploadAreaRef>(null);
// 清理定时器
useEffect(() => {
return () => {
if (progressIntervalRef.current) {
clearInterval(progressIntervalRef.current);
}
if (processingIntervalRef.current) {
clearInterval(processingIntervalRef.current);
}
};
}, []);
// 处理文件选择
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]);
console.log("currentFile", currentFile);
startUpload(selectedFiles[0]);
}, [fileType, currentFile]);
// 开始上传文件
const startUpload = (file: File) => {
setUploadStage("uploading");
setUploadProgress(0);
// 更新步骤状态
const updatedSteps = [...processingSteps];
updatedSteps[0].status = "active";
updatedSteps[0].description = `正在上传文件"${file.name}"到服务器...`;
setProcessingSteps(updatedSteps);
// 模拟上传进度
if (progressIntervalRef.current) {
clearInterval(progressIntervalRef.current);
}
progressIntervalRef.current = setInterval(() => {
setUploadProgress(prev => {
const newProgress = prev + 5;
// 根据文件大小调整上传速度
const speedFactor = Math.min(file.size / (1024 * 1024) + 1, 5);
setUploadSpeed(`${Math.floor(Math.random() * 100 * speedFactor) + 50}KB/s`);
if (newProgress >= 100) {
if (progressIntervalRef.current) {
clearInterval(progressIntervalRef.current);
setUploadSpeed("完成");
// 完成上传后开始处理流程
startProcessing();
}
return 100;
}
return newProgress;
});
}, 200);
};
// 开始处理文件
const startProcessing = () => {
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();
}
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++;
// 如果这是最后一个步骤,确保完成
// if (currentStepIndex >= processingSteps.length) {
// setTimeout(() => {
// completeProcessing();
// }, 1000);
// }
}, 2000);
}, 2500);
};
// 完成处理流程
const completeProcessing = () => {
// 设置当前状态为已完成
setUploadStage("completed");
// 创建完成的文件对象
if (currentFile) {
console.log("创建完成的文件对象...");
const newFile: UploadedFile = {
id: `file_${Date.now()}`,
name: currentFile.name,
size: currentFile.size,
type: currentFile.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) => (
<div className="flex items-center">
<i className={`${record.type.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: "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 (
<span className={`file-type-badge ${typeClass}`}>
<i className={typeIcon}></i>
{FILE_TYPE_LABELS[record.fileType]}
</span>
);
}
},
{
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 (
<span className={`status-badge ${statusClass}`}>
<i className={`${statusIcon} mr-1`}></i>
{statusText}
</span>
);
}
},
{
title: "操作",
key: "operation",
width: "15%",
render: (_: unknown, record: UploadedFile) => (
<Button
type="default"
size="small"
disabled={record.status !== ProcessingStatus.SUCCESS}
icon="ri-eye-line"
onClick={() => alert(`查看文件详情: ${record.name}`)}
>
</Button>
)
}
];
return (
<div className="file-upload-page">
{/* 页面头部 */}
<div className="page-header">
<h2 className="page-title"></h2>
</div>
{/* 文件类型选择 */}
<Card title={<h3></h3>} className="mb-4">
<div className="grid grid-cols-1 md:grid-cols-2 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"
className="form-select"
value={fileType}
onChange={(e) => setFileType(e.target.value as FileType)}
disabled={uploadStage !== "idle"}
>
<option value=""></option>
<option value={FileType.CONTRACT}></option>
<option value={FileType.LICENSE}></option>
<option value={FileType.PUNISHMENT}></option>
<option value={FileType.OTHER}></option>
</select>
<div className="form-tip"></div>
</div>
<div className="form-group">
<label htmlFor="priority-select" className="form-label"></label>
<select
id="priority-select"
className="form-select"
value={priority}
onChange={(e) => setPriority(e.target.value as Priority)}
disabled={uploadStage !== "idle"}
>
<option value={Priority.NORMAL}></option>
<option value={Priority.HIGH}></option>
<option value={Priority.URGENT}></option>
</select>
<div className="form-tip"></div>
</div>
</div>
</Card>
{/* 文件上传区域 */}
<Card title={<h3></h3>} className="mb-4">
{/* 初始上传区域 */}
{uploadStage === "idle" && (
<UploadArea
ref={uploadAreaRef}
onFilesSelected={handleFilesSelected}
multiple={false}
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png"
tipText="支持单个或批量上传,文件格式:PDF、Word、Excel、图片"
/>
)}
{/* 上传进度显示 */}
{uploadStage !== "completed" && currentFile && (
<FileProgress
fileName={currentFile.name}
fileSize={formatFileSize(currentFile.size)}
progress={uploadProgress}
speed={uploadSpeed}
/>
)}
{/* 处理步骤显示 */}
{(uploadStage === "processing" || uploadStage === "completed") && (
<div className="mt-4 mb-4">
<ProcessingSteps steps={processingSteps} />
</div>
)}
{/* 完成后的文件信息 */}
{uploadStage === "completed" && completedFile && (
<div className="mt-6">
<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="file-info-grid">
<div>
<h4 className="font-medium mb-3"></h4>
<ul className="file-info-list">
<li className="file-info-item">
<span className="file-info-label"></span>
<span className="file-info-value">{completedFile.name}</span>
</li>
<li className="file-info-item">
<span className="file-info-label"></span>
<span className="file-info-value">{formatFileSize(completedFile.size)}</span>
</li>
<li className="file-info-item">
<span className="file-info-label"></span>
<span className="file-info-value">{completedFile.uploadTime}</span>
</li>
<li className="file-info-item">
<span className="file-info-label"></span>
<span className="file-info-value">{FILE_TYPE_LABELS[completedFile.fileType]}</span>
</li>
<li className="file-info-item">
<span className="file-info-label"></span>
<span className="file-info-value"></span>
</li>
</ul>
</div>
<div>
<h4 className="font-medium mb-3"></h4>
<div className="bg-gray-50 p-3 rounded-md border border-gray-200">
<div className="mb-2">
<span className="text-gray-500 text-sm"></span>
<span className="pulse-animation">XS-2023-1025-001</span>
</div>
<div className="mb-2">
<span className="text-gray-500 text-sm"></span>
<span className="pulse-animation"></span>
</div>
<div className="mb-2">
<span className="text-gray-500 text-sm"></span>
<span className="pulse-animation">20231020</span>
</div>
<div className="mb-2">
<span className="text-gray-500 text-sm"></span>
<span className="pulse-animation">¥ 1,580,000.00</span>
</div>
<div>
<span className="text-gray-500 text-sm"></span>
<span className="pulse-animation">XX烟草公司YY贸易有限公司</span>
</div>
</div>
<div className="mt-4 flex justify-between">
<Button
type="default"
icon="ri-refresh-line"
onClick={resetUpload}
>
</Button>
<Button
type="primary"
icon="ri-file-search-line"
>
</Button>
</div>
</div>
</div>
</div>
)}
</Card>
{/* 上传队列 */}
<Card
title={
<div className="flex justify-between items-center">
<h3></h3>
<span className="text-gray-500 text-sm"> {queueFiles.length} </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>
);
}