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

633 lines
22 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, useRef, useCallback } from "react";
import { type ActionFunctionArgs, type MetaFunction, json } from "@remix-run/node";
import { Form, useActionData, useNavigation, useSubmit } from "@remix-run/react";
import { Card } from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import { Alert } from "~/components/ui/Alert";
import { UploadArea, type UploadAreaRef } from "~/components/ui/UploadArea";
import { FileProgress } from "~/components/ui/FileProgress";
import { FileTag } from "~/components/ui/FileTag";
import documentUploadStyles from "~/styles/pages/document-upload.css?url";
export const links = () => [
{ rel: "stylesheet", href: documentUploadStyles }
];
export const meta: MetaFunction = () => {
return [
{ title: "上传文档 - 中国烟草AI合同及卷宗审核系统" },
{ name: "description", content: "上传文档进行AI审核" }
];
};
export const handle = {
breadcrumb: "上传文档"
};
// 模拟API支持的文件类型
const SUPPORTED_FILE_TYPES = [
{ id: "1", name: "销售合同" },
{ id: "2", name: "采购合同" },
{ id: "3", name: "专卖许可证" },
{ id: "4", name: "行政处罚决定书" },
{ id: "5", name: "承包协议" }
];
// 模拟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: "立即开始审核" }
];
// 定义接口
interface UploadedFile {
id: string;
name: string;
size: number;
status: "waiting" | "uploading" | "success" | "error";
progress: number;
error?: string;
newName?: string;
type: string;
}
interface ActionData {
success?: boolean;
error?: string;
files?: UploadedFile[];
}
// Action函数处理表单提交
export const action = async ({ request }: ActionFunctionArgs) => {
// 在实际应用中,这里应该处理文件上传逻辑
// 例如使用FormData API获取文件并调用后端API
try {
const formData = await request.formData();
const docType = formData.get("docType") as string;
const docNumber = formData.get("docNumber") as string;
const docRemark = formData.get("docRemark") as string;
const isTestDocument = formData.get("isTestDocument") === "true";
const storageType = formData.get("storageType") as string;
const afterUpload = formData.get("afterUpload") as string;
// 在真实情况下,这里将处理文件上传
// 由于Remix在服务器端不直接处理文件,我们将在客户端处理文件上传
// 然后将文件信息发送给服务器
// 模拟处理过程
await new Promise(resolve => setTimeout(resolve, 1000));
return json<ActionData>({
success: true,
files: [] // 服务器处理的文件列表将返回这里
});
} catch (error) {
console.error("Upload error:", error);
return json<ActionData>(
{
success: false,
error: error instanceof Error ? error.message : "文件上传过程中发生错误"
},
{ status: 400 }
);
}
};
// 格式化文件大小
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
// 获取文件扩展名
function getFileExtension(filename: string): string {
return filename.split('.').pop()?.toLowerCase() || "";
}
// 检查文件类型是否支持
function isFileTypeSupported(filename: string): boolean {
const ext = getFileExtension(filename);
return ["pdf", "doc", "docx", "txt"].includes(ext);
}
export default function DocumentUpload() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const submit = useSubmit();
const uploading = navigation.state === "submitting";
const [files, setFiles] = useState<UploadedFile[]>([]);
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const [isTestDocument, setIsTestDocument] = useState(false);
const [uploadComplete, setUploadComplete] = useState(false);
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
const uploadAreaRef = useRef<UploadAreaRef>(null);
const formRef = useRef<HTMLFormElement>(null);
// 处理文件选择
const handleFilesSelected = useCallback((fileList: FileList) => {
const newFiles: UploadedFile[] = [];
Array.from(fileList).forEach(file => {
// 检查文件类型
if (!isFileTypeSupported(file.name)) {
alert(`不支持的文件类型: ${file.name}\n请上传PDF、DOC、DOCX或TXT格式文件`);
return;
}
// 检查文件大小
if (file.size > 50 * 1024 * 1024) { // 50MB
alert(`文件过大: ${file.name}\n文件大小不能超过50MB`);
return;
}
// 检查是否已添加
const isDuplicate = files.some(f => f.name === file.name && f.size === file.size);
if (isDuplicate) {
alert(`文件已添加: ${file.name}`);
return;
}
// 添加新文件
newFiles.push({
id: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
name: file.name,
size: file.size,
status: "waiting",
progress: 0,
type: getFileExtension(file.name)
});
});
setFiles(prev => [...prev, ...newFiles]);
// 重置文件输入,允许再次选择相同文件
uploadAreaRef.current?.resetFileInput();
}, [files]);
// 移除文件
const removeFile = useCallback((fileId: string) => {
setFiles(prev => prev.filter(file => file.id !== fileId));
setSelectedFileIds(prev => prev.filter(id => id !== fileId));
}, []);
// 批量删除文件
const removeSelectedFiles = useCallback(() => {
if (selectedFileIds.length === 0) return;
if (confirm(`确定要删除选中的 ${selectedFileIds.length} 个文件吗?`)) {
setFiles(prev => prev.filter(file => !selectedFileIds.includes(file.id)));
setSelectedFileIds([]);
}
}, [selectedFileIds]);
// 清空文件列表
const clearAllFiles = useCallback(() => {
if (files.length === 0) return;
if (confirm('确定要清空文件列表吗?')) {
setFiles([]);
setSelectedFileIds([]);
}
}, [files.length]);
// 切换文件选择
const toggleFileSelection = useCallback((fileId: string, selected: boolean) => {
if (selected) {
setSelectedFileIds(prev => [...prev, fileId]);
} else {
setSelectedFileIds(prev => prev.filter(id => id !== fileId));
}
}, []);
// 更新文件名
const updateFileName = useCallback((fileId: string, newName: string) => {
setFiles(prev =>
prev.map(file =>
file.id === fileId
? { ...file, newName: newName + '.' + getFileExtension(file.name) }
: file
)
);
}, []);
// 提交表单
const handleSubmit = useCallback((event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = event.currentTarget;
const docType = form.docType.value;
// 表单验证
if (!docType) {
alert('请选择文档类型');
return;
}
if (files.length === 0) {
alert('请至少上传一个文档');
return;
}
// 创建FormData对象
const formData = new FormData(form);
formData.append("isTestDocument", isTestDocument.toString());
// 在实际应用中,这里应该处理文件上传
// 如果Remix不能直接处理文件上传,可以考虑使用预签名URL或其他方法
// 这里我们模拟文件上传进度
simulateUpload();
// 提交表单
submit(formData, { method: "post", encType: "multipart/form-data" });
}, [files.length, isTestDocument, submit]);
// 模拟文件上传进度
const simulateUpload = useCallback(() => {
const updatedFiles = [...files];
// 设置所有文件为上传中状态
updatedFiles.forEach(file => {
file.status = "uploading";
file.progress = 0;
});
setFiles(updatedFiles);
// 模拟进度更新
const interval = setInterval(() => {
setFiles(prevFiles => {
const newFiles = [...prevFiles];
let allComplete = true;
newFiles.forEach(file => {
if (file.status === "uploading") {
// 增加进度
file.progress += Math.random() * 10;
if (file.progress >= 100) {
file.progress = 100;
// 模拟有10%概率上传失败
if (Math.random() > 0.9) {
file.status = "error";
file.error = "上传失败,请重试";
} else {
file.status = "success";
}
} else {
allComplete = false;
}
}
});
// 如果所有文件都完成了,停止定时器
if (allComplete) {
clearInterval(interval);
setTimeout(() => {
// 检查是否有文件上传错误
const hasErrors = newFiles.some(file => file.status === "error");
if (!hasErrors) {
setUploadComplete(true);
}
}, 1000);
}
return newFiles;
});
}, 200);
}, [files]);
// 重新上传文件
const retryUpload = useCallback((fileId: string) => {
setFiles(prev =>
prev.map(file =>
file.id === fileId
? { ...file, status: "uploading", progress: 0, error: undefined }
: file
)
);
// 模拟重新上传
setTimeout(() => {
setFiles(prev =>
prev.map(file => {
if (file.id === fileId) {
const success = Math.random() > 0.1;
return {
...file,
status: success ? "success" : "error",
progress: 100,
error: success ? undefined : "上传失败,请重试"
};
}
return file;
})
);
}, 2000);
}, []);
// 重置表单,继续上传
const resetForm = useCallback(() => {
setFiles([]);
setUploadComplete(false);
setSelectedFileIds([]);
formRef.current?.reset();
}, []);
return (
<div className="document-upload-page">
<div className="page-header">
<h2 className="page-title"></h2>
<div>
<Button to="/documents" type="default" className="mr-2">
<i className="ri-arrow-left-line"></i>
</Button>
<Button
type="primary"
disabled={files.length === 0 || uploading}
onClick={() => formRef.current?.requestSubmit()}
>
<i className="ri-upload-2-line"></i>
</Button>
</div>
</div>
<Card>
{!uploadComplete ? (
<Form ref={formRef} method="post" onSubmit={handleSubmit} encType="multipart/form-data">
<div className="form-grid">
<div className="form-group">
<label className="form-label" htmlFor="docType">
<span className="text-red-500">*</span>
</label>
<select
id="docType"
name="docType"
className="form-select w-full"
required
>
<option value=""></option>
{SUPPORTED_FILE_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="docNumber">
</label>
<input
type="text"
id="docNumber"
name="docNumber"
className="form-input w-full"
placeholder="请输入合同编号、许可证号等"
/>
<div className="form-tip"></div>
</div>
</div>
<div className="form-group">
<label className="form-label" htmlFor="docRemark">
</label>
<textarea
id="docRemark"
name="docRemark"
className="form-textarea w-full"
placeholder="可输入文档的相关描述或备注信息"
rows={2}
></textarea>
</div>
<div className="form-group">
<label className="form-label">
<span className="text-red-500">*</span>
</label>
<UploadArea
ref={uploadAreaRef}
onFilesSelected={handleFilesSelected}
accept=".pdf,.doc,.docx,.txt"
multiple={true}
icon="ri-upload-cloud-line"
mainText="拖拽文件到此处或点击上传"
tipText="支持 PDF、DOC、DOCX、TXT 格式文档,单个文件大小不超过50MB"
disabled={uploading}
/>
<div className="switch-container">
<label className="switch">
<input
type="checkbox"
checked={isTestDocument}
onChange={e => setIsTestDocument(e.target.checked)}
/>
<span className="slider"></span>
</label>
<span></span>
</div>
{files.length > 0 && (
<div className="batch-actions">
<div>
<span className="text-sm"> {selectedFileIds.length} </span>
</div>
<div>
<Button
type="default"
size="small"
className="mr-2"
onClick={removeSelectedFiles}
disabled={selectedFileIds.length === 0 || uploading}
>
<i className="ri-delete-bin-line"></i>
</Button>
<Button
type="default"
size="small"
onClick={clearAllFiles}
disabled={files.length === 0 || uploading}
>
<i className="ri-close-circle-line"></i>
</Button>
</div>
</div>
)}
<div className="file-list">
{files.map(file => (
<div key={file.id} className="file-item">
<input
type="checkbox"
checked={selectedFileIds.includes(file.id)}
onChange={e => toggleFileSelection(file.id, e.target.checked)}
disabled={uploading || file.status === "uploading"}
className="mr-3"
/>
<FileTag
extension={getFileExtension(file.name)}
size="lg"
className="mr-3"
/>
<div className="file-info">
<div className="file-name flex items-center">
<span>{file.newName || file.name}</span>
{file.status !== "uploading" && (
<button
type="button"
className="ml-2 text-primary text-sm"
onClick={() => {
const fileName = file.name;
const nameWithoutExt = fileName.substring(0, fileName.lastIndexOf('.'));
const newName = prompt('编辑文件名', nameWithoutExt);
if (newName) {
updateFileName(file.id, newName);
}
}}
disabled={uploading}
>
<i className="ri-edit-line"></i>
</button>
)}
</div>
<div className="file-meta">
<span className="file-size">{formatFileSize(file.size)}</span>
<span className={`file-status ${file.status === "error" ? "text-red-500" : ""}`}>
{file.status === "waiting" && "等待上传"}
{file.status === "uploading" && "上传中..."}
{file.status === "success" && "上传成功"}
{file.status === "error" && (
<>
{file.error}
<button
type="button"
className="ml-2 text-primary text-xs"
onClick={() => retryUpload(file.id)}
>
<i className="ri-refresh-line"></i>
</button>
</>
)}
</span>
</div>
<div className="progress-bar">
<div className="progress-bar-inner" style={{ width: `${file.progress}%` }}></div>
</div>
</div>
<div className="file-actions">
<Button
type="text"
size="small"
className="text-red-500"
onClick={() => removeFile(file.id)}
disabled={uploading || file.status === "uploading"}
title="删除文件"
>
<i className="ri-delete-bin-line"></i>
</Button>
</div>
</div>
))}
</div>
</div>
<div className="advanced-options">
<div
className={`advanced-options-toggle ${showAdvancedOptions ? 'open' : ''}`}
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
>
<span></span>
<i className="ri-arrow-down-s-line"></i>
</div>
<div
className="advanced-options-content"
style={{ display: showAdvancedOptions ? 'block' : 'none' }}
>
<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>
</Form>
) : (
<div className="upload-complete-actions" style={{ display: "block" }}>
<Alert type="success" className="mb-4">
</Alert>
<div>
<Button type="default" className="mr-2" onClick={resetForm}>
<i className="ri-add-line"></i>
</Button>
<Button to="/documents" type="default" className="mr-2">
<i className="ri-list-check-line"></i>
</Button>
<Button to="/documents/1?action=audit" type="primary">
<i className="ri-play-circle-line"></i>
</Button>
</div>
</div>
)}
</Card>
</div>
);
}