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

965 lines
35 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 { type MetaFunction, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, useSearchParams, useNavigate } from "@remix-run/react";
import { useEffect, useState, useCallback } from "react";
import { Button } from "~/components/ui/Button";
import { Card } from "~/components/ui/Card";
import { FileIcon } from "~/components/ui/FileIcon";
import { FilterPanel, FilterSelect, SearchFilter, DateRangeFilter } from "~/components/ui/FilterPanel";
import { Pagination } from "~/components/ui/Pagination";
import { Table } from "~/components/ui/Table";
import { StatusBadge } from "~/components/ui/StatusBadge";
import { FileTypeTag, links as fileTypeTagLinks } from "~/components/ui/FileTypeTag";
import { NumberSkeleton, TableRowSkeleton, LoadingIndicator } from "~/components/ui/SkeletonScreen";
import rulesFilesStyles from "~/styles/pages/rules-files.css?url";
import {
getReviewFiles,
type ReviewFileUI,
updateDocumentAuditStatus,
type DocumentSearchParams
} from "~/api/evaluation_points/rules-files";
import { getDocumentTypes } from "~/api/document-types/document-types";
import { toastService } from "~/components/ui/Toast";
// 导入axios下载文件方法
import { downloadFile } from "~/api/axios-client";
import { appendContractAttachments } from "~/api/files/files-upload";
import { messageService } from "~/components/ui/MessageModal";
export const links = () => [
{ rel: "stylesheet", href: rulesFilesStyles },
...fileTypeTagLinks()
];
export const handle = {
breadcrumb: "评查文件列表"
};
export const meta: MetaFunction = () => {
return [
{ title: "评查文件列表 - 中国烟草AI合同及卷宗审核系统" },
{ name: "description", content: "管理系统中所有上传的评查文件,支持按文件类型、评查状态进行筛选" },
{ name: "keywords", content: "评查文件,合同审核,中国烟草,文件管理" }
];
};
// 日期范围枚举
export enum DateRange {
ALL = 'all',
TODAY = 'today',
WEEK = 'week',
MONTH = 'month',
CUSTOM = 'custom'
}
// 评查状态标签映射
export const REVIEW_STATUS_LABELS: Record<string, string> = {
'pass': '通过',
'warning': '警告',
'fail': '不通过',
'pending': '待人工确认'
};
// 加载评查文件列表
export async function loader({ request }: LoaderFunctionArgs) {
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { userInfo, frontendJWT } = await getUserSession(request);
// 获取分页参数
const url = new URL(request.url);
const currentPage = parseInt(url.searchParams.get("page") || "1", 10);
const pageSize = parseInt(url.searchParams.get("pageSize") || "10", 10);
try {
// 获取文档类型列表
const typesResponse = await getDocumentTypes({pageSize:500}, frontendJWT);
const documentTypes = typesResponse.data?.types || [];
// 返回初始空数据,客户端将根据 sessionStorage 中的 reviewType 加载实际数据
return Response.json({
files: [],
documentTypes,
totalCount: 0,
currentPage,
pageSize,
userInfo, // 传递用户信息到客户端
frontendJWT,
initialLoad: true
});
} catch (error) {
console.error('加载评查文件列表失败:', error);
return Response.json({ result: false, message: error instanceof Error ? error.message : '加载评查文件列表失败' }, { status: 500 });
}
}
export default function RulesFiles() {
const navigate = useNavigate();
const { files: initialFiles, documentTypes: allDocumentTypes, totalCount: initialTotal, currentPage, pageSize, userInfo, frontendJWT, result, message } = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const dateFrom = searchParams.get('dateFrom') || '';
const dateTo = searchParams.get('dateTo') || '';
// 添加状态管理
const [files, setFiles] = useState<ReviewFileUI[]>(initialFiles);
const [documentTypes, setDocumentTypes] = useState(allDocumentTypes);
const [totalCount, setTotalCount] = useState(initialTotal);
const [isLoading, setIsLoading] = useState(true);
const [reviewType, setReviewType] = useState<string | null>(null);
// 附件追加相关状态
const [showAttachmentUpload, setShowAttachmentUpload] = useState<boolean>(false);
const [selectedDocumentId, setSelectedDocumentId] = useState<string | null>(null);
const [attachmentFiles, setAttachmentFiles] = useState<File[]>([]);
const [attachmentMergeMode, setAttachmentMergeMode] = useState<'overwrite' | 'new'>('overwrite');
const [attachmentRemark, setAttachmentRemark] = useState<string>("");
const [attachmentUploading, setAttachmentUploading] = useState<boolean>(false);
// 保存/恢复 查询参数 的 sessionStorage key
const SEARCH_PARAMS_STORAGE_KEY = 'rulesFiles.searchParams';
const persistSearchParams = useCallback((params: URLSearchParams) => {
if (typeof window !== 'undefined') {
sessionStorage.setItem(SEARCH_PARAMS_STORAGE_KEY, params.toString());
}
}, []);
// 首次进入列表页且 URL 无查询参数时,尝试恢复上次保存的参数
useEffect(() => {
if (typeof window === 'undefined') return;
const hasAnyParam = Array.from(searchParams.keys()).length > 0;
const stored = sessionStorage.getItem(SEARCH_PARAMS_STORAGE_KEY);
if (!hasAnyParam && stored) {
setSearchParams(new URLSearchParams(stored));
}
// 仅在初始渲染时检查
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 处理初始加载数据loader的错误
useEffect(() => {
if(result === false && message) {
toastService.error(message);
}
}, [result, message]);
// 客户端数据请求
const fetchData = useCallback(async (params: Record<string, string>) => {
setIsLoading(true);
try {
// 构建搜索参数
const searchParams: DocumentSearchParams = {
fileType: params.fileType || undefined,
reviewStatus: params.reviewStatus || undefined,
dateFrom: params.dateFrom || undefined,
dateTo: params.dateTo || undefined,
keyword: params.keyword || undefined,
sortOrder: params.sortOrder || 'upload_time_desc',
page: parseInt(params.page || "1", 10),
pageSize: parseInt(params.pageSize || "10", 10)
};
// 根据 reviewType 添加类型过滤
if (reviewType === 'contract') {
searchParams.fileType = 'contract';
} else if (reviewType === 'record') {
// 在 API 层处理 type_id 为 2 或 3 的过滤
searchParams.fileType = 'record';
}
// 如果用户手动选择了文件类型,优先使用用户选择的
if (params.fileType) {
searchParams.fileType = params.fileType;
}
// 从loader data中获取用户ID
const userId = userInfo?.user_id?.toString();
// 获取文件列表
const filesResponse = await getReviewFiles({...searchParams, token: frontendJWT}, null, userId);
if (filesResponse.error) {
throw new Error(filesResponse.error);
}
setFiles(filesResponse.data?.files || []);
setTotalCount(filesResponse.data?.total || 0);
} catch (error) {
console.error('获取评查文件列表失败:', error);
toastService.error('获取评查文件列表失败: ' + (error instanceof Error ? error.message : '未知错误'));
} finally {
setIsLoading(false);
}
}, [reviewType]);
// 在组件挂载时从 sessionStorage 获取 reviewType 并加载数据
useEffect(() => {
try {
if (typeof window !== 'undefined') {
const storedReviewType = sessionStorage.getItem('reviewType');
// 根据 reviewType 过滤文档类型选项
if (storedReviewType) {
setReviewType(storedReviewType);
if (storedReviewType === 'contract') {
// 只保留 id=1 的选项
const filteredTypes = allDocumentTypes.filter((type: {id: number}) => type.id === 1);
setDocumentTypes(filteredTypes);
} else if (storedReviewType === 'record') {
// 只保留 id=2 和 id=3 的选项
const filteredTypes = allDocumentTypes.filter((type: {id: number}) => type.id === 2 || type.id === 3 || type.id === 155);
setDocumentTypes(filteredTypes);
}
// 直接使用 storedReviewType 构建搜索参数
const currentParams = Object.fromEntries(searchParams.entries());
const apiSearchParams: DocumentSearchParams = {
fileType: currentParams.fileType || undefined,
reviewStatus: currentParams.reviewStatus || undefined,
dateFrom: currentParams.dateFrom || undefined,
dateTo: currentParams.dateTo || undefined,
keyword: currentParams.keyword || undefined,
sortOrder: currentParams.sortOrder || 'upload_time_desc',
page: parseInt(currentParams.page || "1", 10),
pageSize: parseInt(currentParams.pageSize || "10", 10)
};
// 根据 storedReviewType 添加类型过滤
if (storedReviewType === 'contract') {
apiSearchParams.fileType = '1';
} else if (storedReviewType === 'record') {
apiSearchParams.fileType = 'record';
}
// 如果用户手动选择了文件类型,优先使用用户选择的
if (currentParams.fileType) {
apiSearchParams.fileType = currentParams.fileType;
}
// 设置加载状态
setIsLoading(true);
// 从loader data中获取用户ID
const userId = userInfo?.user_id?.toString();
// 添加 token 参数到 apiSearchParams
apiSearchParams.token = frontendJWT;
// 获取文件列表
getReviewFiles(apiSearchParams, null, userId)
.then(filesResponse => {
if (filesResponse.error) {
throw new Error(filesResponse.error);
}
setFiles(filesResponse.data?.files || []);
setTotalCount(filesResponse.data?.total || 0);
})
.catch(error => {
console.error('获取评查文件列表失败:', error);
toastService.error('获取评查文件列表失败: ' + (error instanceof Error ? error.message : '未知错误'));
})
.finally(() => {
setIsLoading(false);
});
}
}
} catch (error) {
console.error('获取 sessionStorage 中的 reviewType 失败:', error);
}
}, [allDocumentTypes, searchParams]);
// 监听 URL 参数变化,重新获取数据
useEffect(() => {
if (reviewType) {
fetchData(Object.fromEntries(searchParams.entries()));
}
}, [searchParams, fetchData, reviewType]);
// 处理筛选条件变更
const handleFilterChange = (e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>) => {
const { name, value } = e.target;
const newParams = new URLSearchParams(searchParams);
if (value) {
newParams.set(name, value);
} else {
newParams.delete(name);
}
// 切换筛选条件时,重置到第一页
newParams.set('page', '1');
persistSearchParams(newParams);
setSearchParams(newParams);
};
// 处理搜索操作
const handleSearch = (keyword: string) => {
const newParams = new URLSearchParams(searchParams);
if (keyword) {
newParams.set('keyword', keyword);
} else {
newParams.delete('keyword');
}
// 搜索时,重置到第一页
newParams.set('page', '1');
persistSearchParams(newParams);
setSearchParams(newParams);
};
// 处理页码变更
const handlePageChange = (page: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('page', page.toString());
persistSearchParams(newParams);
setSearchParams(newParams);
};
// 处理每页条数变更
const handlePageSizeChange = (size: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('pageSize', size.toString());
newParams.set('page', '1'); // 改变每页条数时重置为第一页
persistSearchParams(newParams);
setSearchParams(newParams);
};
// 查看评查文件
const handleReviewFileClick = async (fileId: string, auditStatus: number | null) => {
// 检查audit_status是否为0,如果是则更新为2
if (auditStatus === 0 || auditStatus === null) {
try {
// 从loader data中获取用户ID
const userId = userInfo?.user_id?.toString();
if (!userId) {
toastService.error('用户身份验证失败');
return;
}
const response = await updateDocumentAuditStatus(fileId, 2, userId, frontendJWT);
if (response.error) {
throw new Error(response.error);
}
} catch (error) {
console.error('更新文件审核状态时出错:', error);
toastService.error(`更新文件审核状态时出错:${error instanceof Error ? error.message : '未知错误'}`);
return;
}
}
// 导航到评查详情页
// 在离开当前页前保存当前查询参数,返回时可恢复
if (typeof window !== 'undefined') {
sessionStorage.setItem(SEARCH_PARAMS_STORAGE_KEY, searchParams.toString());
}
navigate(`/reviews?id=${fileId}&previousRoute=rulesFiles`);
};
// 渲染问题摘要
const renderIssues = (file: ReviewFileUI) => {
// 如果文件状态为完成
if (file.status === 'Processed') {
// 如果没有问题,显示"所有评查点均通过"
if (file.warningCount <= 0 && file.failCount <= 0) {
return (
<div className="text-sm text-success">
<i className="ri-check-double-line mr-1"></i>
</div>
);
}
// 如果评查状态为不通过,显示"统计分数为:{file.score || 0}。分数低于80分。"
// if (file.reviewStatus === 'fail') {
// return (
// <div className="text-sm text-error">
// <i className="ri-error-warning-line mr-1"></i>统计分数为:{file.score || 0}。分数低于80分。
// </div>
// );
// }
// 显示问题列表
if (file.issues && file.issues.length > 0) {
// 最多显示2个问题
const displayIssues = file.issues.slice(0, 2);
return (
<div className="text-sm">
{displayIssues.map((issue, index) => (
<div key={index} className="mb-1">
<i className="ri-circle-fill mr-1 text-warning"></i>
{issue.message}
</div>
))}
{file.issues.length > 2 && (
<div className="text-secondary mt-1">
{file.issues.length - 2} ...
</div>
)}
</div>
);
}
}
// 其他状态显示占位符
return <div className="text-sm text-secondary">-</div>;
};
// 下载文件
const handleDownload = async (path: string) => {
try {
// 使用axios封装的下载方法
const blob = await downloadFile(path);
// 创建Blob URL
const blobUrl = URL.createObjectURL(blob);
// 创建一个隐藏的a标签并点击它
const a = document.createElement('a');
a.style.display = 'none';
a.href = blobUrl;
// 从路径中获取文件名
const fileName = path.split('/').pop() || 'document';
a.download = decodeURIComponent(fileName);
document.body.appendChild(a);
a.click();
// 清理
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
}, 100);
} catch (error) {
console.error('下载文件失败:', error);
toastService.error(`下载文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
};
// 处理时间范围变更
const handleDateChange = (field: 'dateFrom' | 'dateTo', value: string) => {
const newParams = new URLSearchParams(searchParams);
if(value) {
newParams.set(field, value);
} else {
newParams.delete(field);
}
newParams.set('page', '1');
setSearchParams(newParams);
};
const handleReset = () => {
const newParams = new URLSearchParams();
const searchInput = document.querySelector('input[name="keyword"]');
if(searchInput) {
(searchInput as HTMLInputElement).value = '';
}
if (typeof window !== 'undefined') {
sessionStorage.removeItem(SEARCH_PARAMS_STORAGE_KEY);
}
setSearchParams(newParams);
};
// 辅助:格式化文件大小
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 handleAttachmentFilesSelected = (files: FileList) => {
try {
if (files.length > 0) {
const validFiles: File[] = [];
let hasInvalidFiles = false;
Array.from(files).forEach(file => {
const fileName = file.name.toLowerCase();
const isValidType =
file.type === 'application/pdf' || fileName.endsWith('.pdf') ||
file.type === 'application/msword' || fileName.endsWith('.doc') ||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || fileName.endsWith('.docx') ||
file.type === 'application/zip' || fileName.endsWith('.zip') ||
file.type === 'application/x-rar-compressed' || fileName.endsWith('.rar');
if (isValidType) {
validFiles.push(file);
} else {
hasInvalidFiles = true;
}
});
if (hasInvalidFiles) {
messageService.error('只支持PDF、Word、ZIP、RAR格式的文件', {
title: '文件类型错误',
confirmText: '确定',
cancelText: '',
});
}
if (validFiles.length > 0) {
setAttachmentFiles(validFiles);
}
}
} catch (error) {
console.error('【附件追加】处理文件选择时发生错误:', error);
}
};
// 执行附件追加
const handleAttachmentUpload = async () => {
if (!selectedDocumentId || attachmentFiles.length === 0) {
toastService.error('请选择文档和附件文件');
return;
}
try {
setAttachmentUploading(true);
const docId = parseInt(selectedDocumentId, 10);
const result = await appendContractAttachments(
docId,
attachmentFiles,
attachmentMergeMode,
true,
attachmentRemark || undefined,
frontendJWT as string | undefined
);
if (result.error) {
throw new Error(result.error);
}
toastService.success('附件追加成功!');
// 重置并关闭
setAttachmentFiles([]);
setAttachmentRemark("");
setShowAttachmentUpload(false);
setSelectedDocumentId(null);
// 刷新列表
fetchData(Object.fromEntries(searchParams.entries()));
} catch (error) {
console.error('【附件追加】上传失败:', error);
toastService.error(error instanceof Error ? error.message : '附件追加失败');
} finally {
setAttachmentUploading(false);
}
};
// 文件类型选项
const fileTypeOptions = documentTypes.map((type: {id: number, name: string}) => ({
value: type.id.toString(),
label: type.name
}));
// 定义表格列配置
const columns = [
{
title: "文件名称",
key: "fileName",
width: "30%",
render: (_: unknown, file: ReviewFileUI) => (
<div className="flex">
<div className="flex-shrink-0 flex items-center self-center">
<FileIcon fileName={file.fileName} className="text-lg w-10 h-10" />
</div>
<div className="min-w-0 flex-1 flex flex-col py-2 ml-2">
<div className="font-normal text-base break-words whitespace-normal leading-normal" title={file.fileName}>{file.fileName}</div>
<div className="text-xs text-secondary mt-2">
{file.fileCode}
</div>
</div>
</div>
)
},
{
title: "文件类型",
key: "fileType",
width: "12%",
render: (_: unknown, file: ReviewFileUI) => (
<FileTypeTag
type="other"
typeName={file.fileType}
text={file.fileType}
size="sm"
showIcon={false}
colorMode="light"
/>
)
},
{
title: "上传时间",
key: "uploadTime",
width: "12%",
render: (_: unknown, file: ReviewFileUI) => {
const [date, time] = file.uploadTime.split(' ');
return (
<div>
<span className="text-base">{date}</span>
<br />
<span className="text-xs text-secondary">{time}</span>
</div>
);
}
},
{
title: "评查统计",
key: "reviewStatus",
width: "12%",
render: (_: unknown, file: ReviewFileUI) =>
// 要文件切分处理完之后,再显示评查统计
file.status === 'Processed' ? (
<div>
{file.passCount > 0 && (
<StatusBadge
status="pass"
text={`通过(${file.passCount})`}
showIcon={true}
className="my-2"
/>
)}
{file.warningCount > 0 && (
<StatusBadge
status="warning"
text={`警告(${file.warningCount})`}
showIcon={true}
className="my-2"
/>
)}
{file.failCount > 0 && (
<StatusBadge
status="fail"
text={`不通过(${file.failCount})`}
showIcon={true}
className="my-2"
/>
)}
{file.manualCount > 0 && (
<StatusBadge
status="pending"
text={`需人工(${file.manualCount})`}
showIcon={true}
className="my-2"
/>
)}
</div>
) : (
<div className="text-sm">
-
</div>
)
},
{
title: "问题摘要",
key: "issues",
width: "20%",
render: (_: unknown, file: ReviewFileUI) => renderIssues(file)
},
{
title: "操作",
key: "operation",
width: "20%",
render: (_: unknown, file: ReviewFileUI) => (
<div className="flex flex-wrap gap-1">
<button
type="button"
onClick={() => handleReviewFileClick(file.id, file.auditStatus)}
disabled={file.status !== 'Processed'}
className={`text-xs px-2 py-1 h-7 mr-1 ${file.status === 'Processed' ? 'hover:underline hover:text-primary' : 'opacity-60 cursor-not-allowed pointer-events-none'}`}
>
<i className="ri-eye-line"></i>
</button>
{file.fileTypeId === 1 && file.status === 'Processed' && (
<button
type="button"
className="text-xs px-2 py-1 h-7 mr-1 hover:underline hover:text-primary"
onClick={() => {
setSelectedDocumentId(file.id);
setShowAttachmentUpload(true);
}}
>
<i className="ri-attachment-line"></i>
</button>
)}
<button
type="button"
className="text-xs px-2 py-1 h-7 mr-1 text-gray-500 hover:underline hover:text-gray-700"
onClick={() => handleDownload(file.path)}
>
<i className="ri-download-2-line"></i>
</button>
</div>
)
}
];
return (
<div className="review-files-page">
{/* 页面头部 */}
<div className="flex justify-between items-center mb-4">
<div className="flex items-center">
<h2 className="text-xl font-normal"></h2>
<div className="flex items-center ml-4 bg-white px-3 py-1 rounded-md">
<i className="ri-file-list-3-line text-primary text-lg mr-1"></i>
<span className="text-sm text-secondary"></span>
{isLoading ? (
<NumberSkeleton className="ml-1" />
) : (
<span className="text-base font-normal text-primary ml-1">{totalCount}</span>
)}
</div>
</div>
<Button type="primary" icon="ri-file-upload-line" to="/files/upload">
</Button>
</div>
{/* 筛选区域 */}
<FilterPanel className="px-3 py-3" noActionDivider={true}
actions={
<>
<Button type="default" icon="ri-refresh-line" onClick={handleReset} className="mr-2 hover:!border-gray-300">
</Button>
</>
}
>
<FilterSelect
label="文件类型"
name="fileType"
value={searchParams.get('fileType') || ''}
options={fileTypeOptions}
onChange={handleFilterChange}
className="mr-2 w-40"
/>
{/* <FilterSelect
label="评查状态"
name="reviewStatus"
value={searchParams.get('reviewStatus') || ''}
options={reviewStatusOptions}
onChange={handleFilterChange}
className="mr-2 w-40"
/> */}
{/* <FilterSelect
label="时间范围"
name="dateRange"
value={searchParams.get('dateRange') || ''}
options={dateRangeOptions}
onChange={handleFilterChange}
className="mr-2 w-40"
/> */}
<DateRangeFilter
label="时间范围"
startDate={dateFrom}
endDate={dateTo}
onStartDateChange={(value) => handleDateChange('dateFrom', value)}
onEndDateChange={(value) => handleDateChange('dateTo', value)}
simple={true}
colorMode="light"
/>
<FilterSelect
label="排序方式"
name="sortOrder"
value={searchParams.get('sortOrder') || 'upload_time_desc'}
onChange={handleFilterChange}
className="w-32"
options={[
{ value: "upload_time_desc", label: "上传时间 ↓" },
{ value: "upload_time_asc", label: "上传时间 ↑" },
// { value: "issue_count_desc", label: "问题数量 ↓" },
// { value: "issue_count_asc", label: "问题数量 ↑" }
]}
/>
<SearchFilter
label="搜索"
placeholder="搜索文件名、合同编号"
value={searchParams.get('keyword') || ''}
onSearch={handleSearch}
buttonText=""
className="mr-2 flex-1"
/>
</FilterPanel>
{/* 文件列表 */}
<Card>
<div className={isLoading ? "opacity-70 pointer-events-none transition-opacity" : ""}>
{isLoading && <LoadingIndicator />}
{isLoading && files.length === 0 ? (
<TableRowSkeleton count={5} />
) : (
<Table
columns={columns}
dataSource={files}
rowKey="id"
emptyText={isLoading ? "加载中..." : "暂无文件数据"}
className="files-table table-auto-height"
/>
)}
{/* 分页组件 */}
{totalCount > 0 && (
<Pagination
currentPage={currentPage}
total={totalCount}
pageSize={pageSize}
onChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
showTotal={true}
showPageSizeChanger={true}
pageSizeOptions={[10, 20, 30, 50]}
/>
)}
</div>
{/* 附件追加模态框 */}
{showAttachmentUpload && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold"></h3>
<button
onClick={() => {
setShowAttachmentUpload(false);
setSelectedDocumentId(null);
setAttachmentFiles([]);
setAttachmentRemark("");
}}
className="text-gray-400 hover:text-gray-600"
>
<i className="ri-close-line text-xl"></i>
</button>
</div>
<div className="space-y-4">
<div className="bg-gray-50 p-3 rounded">
<p className="text-sm text-gray-600">
ID: <span className="font-medium">{selectedDocumentId}</span>
</p>
<p className="text-xs text-gray-500 mt-1">
PDFWordZIPRAR格式ZIP/RAR内仅合并其中的PDF文件
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<span className="text-red-500">*</span>
</label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-gray-400 transition-colors">
<input
type="file"
multiple
accept=".pdf,.doc,.docx,.zip,.rar"
onChange={(e) => e.target.files && handleAttachmentFilesSelected(e.target.files)}
className="hidden"
id="attachment-file-input"
/>
<label htmlFor="attachment-file-input" className="cursor-pointer">
<i className="ri-attachment-line text-3xl text-gray-400 mb-2 block"></i>
<p className="text-sm text-gray-600"></p>
<p className="text-xs text-gray-500 mt-1">PDFWordZIPRAR格式</p>
</label>
</div>
{attachmentFiles.length > 0 && (
<div className="mt-2">
<p className="text-sm text-green-600 mb-2">
<i className="ri-checkbox-circle-line"></i> {attachmentFiles.length}
</p>
<div className="space-y-1 max-h-32 overflow-y-auto">
{attachmentFiles.map((file, index) => (
<div key={index} className="text-xs text-gray-600 bg-gray-50 p-2 rounded">
<i className="ri-file-line mr-1"></i>
{file.name} ({formatFileSize(file.size)})
</div>
))}
</div>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="mergeMode"
value="overwrite"
checked={attachmentMergeMode === 'overwrite'}
onChange={(e) => setAttachmentMergeMode(e.target.value as 'overwrite' | 'new')}
className="mr-2"
/>
<span className="text-sm"></span>
</label>
<label className="flex items-center">
<input
type="radio"
name="mergeMode"
value="new"
checked={attachmentMergeMode === 'new'}
onChange={(e) => setAttachmentMergeMode(e.target.value as 'overwrite' | 'new')}
className="mr-2"
/>
<span className="text-sm"></span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<textarea
value={attachmentRemark}
onChange={(e) => setAttachmentRemark(e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md text-sm"
rows={3}
placeholder="请输入备注信息..."
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t">
<button
className="px-4 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-50"
onClick={() => {
setShowAttachmentUpload(false);
setSelectedDocumentId(null);
setAttachmentFiles([]);
setAttachmentRemark("");
}}
disabled={attachmentUploading}
>
</button>
<button
className="px-4 py-2 text-sm bg-primary text-white rounded-md hover:bg-primary-dark disabled:opacity-50"
onClick={handleAttachmentUpload}
disabled={attachmentFiles.length === 0 || attachmentUploading}
>
{attachmentUploading ? '上传中...' : '开始追加'}
</button>
</div>
</div>
</div>
</div>
)}
</Card>
</div>
);
}
// 错误边界
export function ErrorBoundary() {
return (
<div className="error-container p-6">
<h1 className="text-xl font-normal text-red-500 mb-4"></h1>
<p className="mb-4"></p>
<Button type="primary" to="/"></Button>
</div>
);
}