633 lines
22 KiB
TypeScript
633 lines
22 KiB
TypeScript
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>
|
||
);
|
||
}
|