965 lines
35 KiB
TypeScript
965 lines
35 KiB
TypeScript
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">
|
||
支持PDF、Word、ZIP、RAR格式,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">支持PDF、Word、ZIP、RAR格式,可多选</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>
|
||
);
|
||
} |