Files
leaudit-platform-frontend/app/routes/documents.list.tsx
T
LiangShiyong 616f059f1e feat: 1. 完善评查点分组的删除逻辑,会涉及文档类型绑定的一级分组,分组绑定的评查点规则。新增一个评查点分组换绑的。
2. 修复交叉评查的任务中的文档列表的历史文档的查看跳转路径。
3. 修复评查点新增中评查点类型只能显示当前文档类型绑定的这几个一级分组。评查点类型=一级分组。
4. 修复文档列表关于pdf的下载失败的问题。
2025-12-19 00:21:49 +08:00

2058 lines
77 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 { useSearchParams, useLoaderData, useFetcher, useNavigate,Link } from "@remix-run/react";
import { type MetaFunction, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Card } from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import { usePermission, PermissionGuard } from "~/hooks/usePermission";
// import { Table } from "~/components/ui/Table";
import { Pagination } from "~/components/ui/Pagination";
import { FileTypeTag } from "~/components/ui/FileTypeTag";
import { FileTag } from "~/components/ui/FileTag";
import { FilterPanel, FilterSelect, SearchFilter, DateRangeFilter } from "~/components/ui/FilterPanel";
import { TableRowSkeleton, LoadingIndicator, NumberSkeleton } from '~/components/ui/SkeletonScreen';
import documentsIndexStyles from "~/styles/pages/documents_index.css?url";
import documentVersionStyles from "~/styles/components/document-version.css?url";
import { getDocumentTypesByIds, deleteDocument, getDocumentsListFromAPI, type DocumentUI, type DocumentVersionUI } from "~/api/files/documents";
// import { IssuesDiff } from "~/components/ui/IssuesDiff";
import { ResultStats } from "~/components/ui/ResultStats";
import { updateDocumentAuditStatus } from "~/api/evaluation_points/rules-files";
import { appendContractAttachments, uploadContractTemplate } from "~/api/files/files-upload";
import { toastService } from "~/components/ui/Toast";
import { messageService } from "~/components/ui/MessageModal";
import { loadingBarService } from "~/components/ui/LoadingBar";
// import { DOCUMENT_URL } from "~/api/axios-client";
// 导入样式
export function links() {
return [
{ rel: "stylesheet", href: documentsIndexStyles },
{ rel: "stylesheet", href: documentVersionStyles }
];
}
// 元数据
export const meta: MetaFunction = () => {
return [
{ title: "文档列表 - 中国烟草AI合同及卷宗审核系统" },
{ name: "description", content: "查看和管理系统中的所有文档,包括合同、许可证和行政处罚决定书等" },
];
};
// 数据加载器
export const loader = async ({ request }: LoaderFunctionArgs) => {
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { userInfo, frontendJWT } = await getUserSession(request);
// 获取URL查询参数,只保留必要的分页参数
const url = new URL(request.url);
const page = parseInt(url.searchParams.get("page") || "1", 10);
const pageSize = parseInt(url.searchParams.get("pageSize") || "10", 10);
// 获取文档类型列表(服务端需要显式传递 token,客户端依赖 axios 拦截器)
// const typesResponse = await getDocumentTypes(frontendJWT);
// const documentTypes = typesResponse.data?.types || [];
// const documentTypeOptions = documentTypes.map(type => ({
// value: type.id,
// label: type.name
// }));
// 初始返回空数据,将在客户端根据 sessionStorage 中的 documentTypeIds 加载实际数据
return Response.json({
documents: [],
total: 0,
page,
pageSize,
documentTypeOptions: [],
userInfo, // 传递用户信息到客户端
frontendJWT, // 传递 JWT 到客户端
initialLoad: true // 标记这是初始加载
});
};
// 定义action返回的数据类型
interface ActionResponse {
result: boolean;
message: string;
}
// 审核状态筛选选项
const auditStatusOptions = [
// { value: "", label: "全部" },
// { value: "-2", label: "警告" },
{ value: "-1", label: "不通过" },
{ value: "0", label: "待审核" },
{ value: "1", label: "通过" },
{ value: "2", label: "审核中" },
];
// 文件处理状态选项
const fileProcessingStatusOptions = [
{ value: "Waiting", label: "上传中", icon: "ri-loader-line", color: "blue" },
{ value: "Cutting", label: "切分中", icon: "ri-loader-line", color: "purple" },
{ value: "Extractioning", label: "抽取中", icon: "ri-loader-line", color: "cyan" },
{ value: "Evaluationing", label: "评查中", icon: "ri-loader-line", color: "teal" },
{ value: "Failed", label: "抽取异常", icon: "ri-close-circle-line", color: "red" },
{ value: "Processed", label: "已完成", icon: "ri-check-line", color: "green" },
];
// 文件状态筛选选项
const fileStatusOptions = [
// { value: "", label: "全部" },
{ value: "Waiting", label: "上传中" },
{ value: "Cutting", label: "切分中" },
{ value: "Extractioning", label: "抽取中" },
{ value: "Evaluationing", label: "评查中" },
{ value: "Failed", label: "抽取异常" },
{ value: "Processed", label: "已完成" },
];
// 审核状态选项及样式
const auditStatusMapping: Record<string, { label: string; color: string; icon: string }> = {
"-1": { label: "不通过", color: "red", icon: "ri-close-line" },
"-2": { label: "警告", color: "yellow", icon: "ri-alert-line" },
"0": { label: "待审核", color: "blue", icon: "ri-time-line" },
"1": { label: "通过", color: "green", icon: "ri-check-line" },
"2": { label: "审核中", color: "purple", icon: "ri-search-line" },
};
// 格式化文件大小
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];
};
// 处理表单提交和删除等操作
export const action = async ({ request }: ActionFunctionArgs) => {
try {
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { userInfo, frontendJWT } = await getUserSession(request);
if (!userInfo?.user_id) {
return Response.json({ result: false, message: "用户身份验证失败" }, { status: 401 });
}
const userId = userInfo.user_id.toString();
const formData = await request.formData();
const action = formData.get("_action");
if (action === "delete") {
const id = formData.get("id") as string;
const response = await deleteDocument(id, userId, frontendJWT);
if (response.error) {
return Response.json({ result: false, message: response.error }, { status: response.status || 500 });
}
return Response.json({ result: true, message: "文档已成功删除" });
}
if (action === "batchDelete") {
const ids = formData.getAll("ids") as string[];
// 批量删除处理
const results = await Promise.all(ids.map(id => deleteDocument(id, userId, frontendJWT)));
const failures = results.filter(r => r.error);
if (failures.length > 0) {
return Response.json({
result: false,
message: `删除失败: ${failures.map(f => f.error).join(', ')}`
}, { status: 400 });
}
return Response.json({ result: true, message: `已成功删除${ids.length}个文档` });
}
// 未知操作
return Response.json({ result: false, message: "未知操作" }, { status: 400 });
} catch (error) {
console.error('处理表单提交和删除等操作失败:', error);
return Response.json({
result: false,
error: error instanceof Error ? error.message : "处理表单提交和删除等操作失败"
}, { status: 500 });
}
};
export default function DocumentsIndex() {
const [searchParams, setSearchParams] = useSearchParams();
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const loaderData = useLoaderData<typeof loader>();
const fetcher = useFetcher<ActionResponse>();
const navigate = useNavigate();
// 权限控制
const { hasPermission } = usePermission();
const canView = hasPermission('document:document:view');
const canUpdate = hasPermission('document:document:update');
// 存储从 sessionStorage 获取的 documentTypeIds
const [documentTypeIds, setDocumentTypeIds] = useState<number[] | null>(null);
// 添加页面加载状态管理
const [isLoadingData, setIsLoadingData] = useState(true);
// 是否已完成初始化(区分"还没开始加载"和"加载完成但没有数据")
const [hasInitialized, setHasInitialized] = useState(false);
const [documents, setDocuments] = useState<DocumentUI[]>([]);
const [total, setTotal] = useState(0);
const [filteredDocumentTypeOptions, setFilteredDocumentTypeOptions] = useState(loaderData.documentTypeOptions);
const dataCache = useRef<typeof loaderData | null>(null);
// 添加一个状态来跟踪是否执行了删除操作
const [isDeleting, setIsDeleting] = useState(false);
// 辅助函数:从 localStorage 获取用户ID(与 token 管理保持一致)
const getUserId = useCallback((): string | undefined => {
if (typeof window === 'undefined') return undefined;
const userInfoStr = localStorage.getItem('user_info');
if (!userInfoStr) return undefined;
try {
const userInfoData = JSON.parse(userInfoStr);
return userInfoData.user_id?.toString();
} catch (error) {
console.error('解析 localStorage 用户信息失败:', error);
return undefined;
}
}, []);
// 版本管理:展开的文档行
const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set());
// 版本管理:正在加载历史版本的文档
const [loadingHistory, setLoadingHistory] = useState<Set<number>>(new Set());
// 附件追加和模板上传状态
const [showAttachmentUpload, setShowAttachmentUpload] = useState<boolean>(false);
const [showTemplateUpload, setShowTemplateUpload] = useState<boolean>(false);
const [selectedDocumentId, setSelectedDocumentId] = useState<number | null>(null);
const [selectedDocumentName, setSelectedDocumentName] = useState<string | null>(null);
const [selectedDocumentVersion, setSelectedDocumentVersion] = useState<number | null>(null);
const [attachmentFiles, setAttachmentFiles] = useState<File[]>([]);
const [templateFile, setTemplateFile] = useState<File | null>(null);
const [attachmentMergeMode, setAttachmentMergeMode] = useState<'overwrite' | 'new'>('new');
const [attachmentRemark, setAttachmentRemark] = useState<string>("");
const [attachmentUploading, setAttachmentUploading] = useState<boolean>(false);
const [templateUploading, setTemplateUploading] = useState<boolean>(false);
// 拖拽状态
const [isDraggingAttachment, setIsDraggingAttachment] = useState<boolean>(false);
const [isDraggingTemplate, setIsDraggingTemplate] = useState<boolean>(false);
// 查询参数记忆 key 与保存/恢复方法
const SEARCH_PARAMS_STORAGE_KEY = 'documents.searchParams';
const persistSearchParams = (params: URLSearchParams) => {
if (typeof window !== 'undefined') {
sessionStorage.setItem(SEARCH_PARAMS_STORAGE_KEY, params.toString());
}
};
// 首次进入且 URL 无任何查询参数时,尝试从 sessionStorage 恢复
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
}, []);
// 从URL获取当前筛选条件
const search = searchParams.get("search") || "";
const documentType = searchParams.get("documentType") || "";
const auditStatus = searchParams.get("auditStatus") || "";
const documentNumber = searchParams.get("documentNumber") || "";
const fileStatus = searchParams.get("fileStatus") || "";
const dateFrom = searchParams.get("dateFrom") || "";
const dateTo = searchParams.get("dateTo") || "";
const currentPage = parseInt(searchParams.get("page") || "1", 10);
const pageSize = parseInt(searchParams.get("pageSize") || "10", 10);
// 客户端数据请求
const fetchData = useCallback(async (typeIds: number[]) => {
setIsLoadingData(true);
loadingBarService.show();
try {
// 从 loaderData 获取 JWT Token
const jwtToken = loaderData.frontendJWT;
if (!jwtToken) {
toastService.error('用户身份验证失败,无法获取文档列表');
setIsLoadingData(false);
loadingBarService.hide();
return;
}
console.log('🔑 [fetchData] 文档类型IDs:', typeIds);
// 调用新的 API 函数
const result = await getDocumentsListFromAPI({
page: currentPage,
pageSize: pageSize,
name: search || undefined,
documentNumber: documentNumber || undefined,
documentTypeIds: documentType ? [parseInt(documentType, 10)] : typeIds, // 如果有单独选择的类型,优先使用
auditStatus: auditStatus || undefined,
fileStatus: fileStatus || undefined,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
token: jwtToken
});
if (result.error) {
toastService.error('获取文档列表失败: ' + result.error);
return;
}
if (!result.data) {
toastService.error('获取文档列表失败: 返回数据为空');
return;
}
// 更新状态
setDocuments(result.data.documents);
setTotal(result.data.total);
// 获取经过过滤的文档类型列表
const filteredTypesResponse = await getDocumentTypesByIds(typeIds, jwtToken);
const filteredDocumentTypes = filteredTypesResponse.data?.types || [];
const filteredOptions = filteredDocumentTypes.map(type => ({
value: type.id,
label: type.name
}));
setFilteredDocumentTypeOptions(filteredOptions);
} catch (error) {
console.error('❌ [fetchData] 获取文档列表失败:', error);
toastService.error('获取文档列表失败: ' + (error instanceof Error ? error.message : '未知错误'));
} finally {
setIsLoadingData(false);
setHasInitialized(true); // 标记初始化完成
loadingBarService.hide();
}
}, [search, documentNumber, documentType, auditStatus, fileStatus, dateFrom, dateTo, currentPage, pageSize, loaderData.frontendJWT]);
// 在组件挂载时从 sessionStorage 获取 documentTypeIds 并加载数据
useEffect(() => {
try {
if (typeof window !== 'undefined') {
const typeIdsStr = sessionStorage.getItem('documentTypeIds');
if (typeIdsStr) {
const typeIds = JSON.parse(typeIdsStr) as number[];
console.log('📋 [useEffect] 从 sessionStorage 获取文档类型IDs:', typeIds);
setDocumentTypeIds(typeIds);
// 加载数据(fetchData 中会自动获取并设置过滤后的文档类型选项)
fetchData(typeIds);
} else {
console.warn('⚠️ [useEffect] sessionStorage 中没有 documentTypeIds');
// 没有 documentTypeIds 时,标记初始化完成但无数据
setIsLoadingData(false);
setHasInitialized(true);
loadingBarService.hide();
}
}
} catch (error) {
console.error('❌ [useEffect] 获取 sessionStorage 中的 documentTypeIds 失败:', error);
// 出错时也标记初始化完成
setIsLoadingData(false);
setHasInitialized(true);
loadingBarService.hide();
}
}, [fetchData]);
// 监听 URL 参数变化,重新获取数据
useEffect(() => {
if (documentTypeIds) {
fetchData(documentTypeIds);
}
}, [searchParams, fetchData, documentTypeIds]);
// 监听 documents 数据变化,自动修正不一致的展开状态
useEffect(() => {
if (documents.length === 0) return;
const newExpandedRows = new Set(expandedRows);
let hasChanges = false;
// 检查每个展开的行
expandedRows.forEach(docId => {
const doc = documents.find(d => d.id === docId);
// 如果文档不存在或没有历史版本数据,自动折叠
if (!doc || !doc.historyVersions || doc.historyVersions.length === 0) {
console.warn(`自动折叠文档 ${docId}:数据不完整`);
newExpandedRows.delete(docId);
hasChanges = true;
}
});
// 如果有变化,更新状态
if (hasChanges) {
setExpandedRows(newExpandedRows);
// 同时更新 documents 中的 isExpanded 状态
setDocuments(prevDocs =>
prevDocs.map(d =>
newExpandedRows.has(d.id) ? { ...d, isExpanded: true } : { ...d, isExpanded: false }
)
);
}
}, [documents, expandedRows]);
// 更新缓存数据并处理 loader 错误
useEffect(() => {
// 设置缓存数据(用于后续可能的优化)
dataCache.current = loaderData;
// 处理loader错误
if (loaderData.error) {
toastService.error(loaderData.error);
setIsLoadingData(false);
setHasInitialized(true);
}
}, [loaderData]);
// 使用useEffect监听fetcher状态变化并显示Toast
useEffect(() => {
if (fetcher.data && fetcher.state === 'idle' && isDeleting) {
// 重置删除状态
setIsDeleting(false);
if (fetcher.data.result) {
toastService.success(fetcher.data.message);
// 删除成功后重新加载数据
if (documentTypeIds) {
fetchData(documentTypeIds);
}
} else if (fetcher.data.message) {
toastService.error(fetcher.data.message);
// 删除失败只显示错误信息,不刷新数据
}
}
}, [fetcher.data, fetcher.state, fetchData, documentTypeIds, isDeleting]);
// 分页处理函数
const handlePageChange = (page: number) => {
const params = new URLSearchParams(searchParams);
params.set("page", page.toString());
persistSearchParams(params);
setSearchParams(params);
};
// 每页条数变更处理函数
const handlePageSizeChange = (size: number) => {
const params = new URLSearchParams(searchParams);
params.set("pageSize", size.toString());
params.set("page", "1"); // 重置到第一页
persistSearchParams(params);
setSearchParams(params);
};
// 处理文档名称搜索
const handleNameSearch = (value: string) => {
const params = new URLSearchParams(searchParams);
if (value) {
params.set("search", value);
} else {
params.delete("search");
}
params.set("page", "1"); // 重置页码
persistSearchParams(params);
setSearchParams(params);
};
// 处理文档编号变更
const handleDocumentNumberChange = (value: string) => {
const params = new URLSearchParams(searchParams);
if (value) {
params.set("documentNumber", value);
} else {
params.delete("documentNumber");
}
params.set("page", "1"); // 重置页码
persistSearchParams(params);
setSearchParams(params);
};
// 处理文档类型变更
const handleDocumentTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const params = new URLSearchParams(searchParams);
if (e.target.value) {
params.set("documentType", e.target.value);
} else {
params.delete("documentType");
}
params.set("page", "1"); // 重置页码
persistSearchParams(params);
setSearchParams(params);
};
// 处理状态变更
const handleStatusChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const params = new URLSearchParams(searchParams);
if (e.target.value) {
params.set("auditStatus", e.target.value);
} else {
params.delete("auditStatus");
}
params.set("page", "1"); // 重置页码
persistSearchParams(params);
setSearchParams(params);
};
// 处理日期范围变更
const handleDateChange = (field: 'dateFrom' | 'dateTo', value: string) => {
const params = new URLSearchParams(searchParams);
if (value) {
params.set(field, value);
} else {
params.delete(field);
}
params.set("page", "1"); // 重置页码
persistSearchParams(params);
setSearchParams(params);
};
// 重置搜索条件
const handleReset = () => {
// 直接重置所有筛选条件的DOM值
const resetInput = (selector: string, value: string = "") => {
const element = document.querySelector<HTMLInputElement | HTMLSelectElement>(selector);
if (element) {
element.value = value;
// 对于搜索框,触发其input事件以激活搜索
if (element instanceof HTMLInputElement && element.type === "text") {
// 创建一个input事件
const event = new Event('input', { bubbles: true });
element.dispatchEvent(event);
}
}
};
// 重置所有搜索字段
resetInput('input[placeholder="请输入文档名称"]');
resetInput('input[placeholder="请输入文档编号"]');
resetInput('select[name="documentType"]');
resetInput('select[name="status"]');
resetInput('input[name="dateFrom"]');
resetInput('input[name="dateTo"]');
// 重置URL参数并清除保存
if (typeof window !== 'undefined') {
sessionStorage.removeItem(SEARCH_PARAMS_STORAGE_KEY);
}
setSearchParams(new URLSearchParams({
page: "1",
pageSize: pageSize.toString()
}));
};
// 行选择变更处理
const handleRowSelectionChange = (id: string) => {
if (selectedRowKeys.includes(id)) {
setSelectedRowKeys(selectedRowKeys.filter(key => key !== id));
} else {
setSelectedRowKeys([...selectedRowKeys, id]);
}
};
// 全选处理
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedRowKeys(documents.map((doc: DocumentUI) => doc.id.toString()));
} else {
setSelectedRowKeys([]);
}
};
// 下载文档
const handleDownload = (path: string) => {
// 使用 PDF 代理路由获取文件,自动添加 JWT 认证
const downloadUrl = `/api/pdf-proxy?path=${encodeURIComponent(path)}`;
// 直接使用链接下载,避免 fetch + blob 在生产环境下对 PDF 的兼容问题
const a = document.createElement('a');
a.style.display = 'none';
a.href = downloadUrl;
// 从路径中获取文件名
const fileName = path.split('/').pop() || 'document';
a.download = decodeURIComponent(fileName);
document.body.appendChild(a);
a.click();
// 清理
setTimeout(() => {
document.body.removeChild(a);
}, 100);
};
// 删除文档
const handleDelete = (id: string, name: string, fileStatus: string) => {
// 禁止删除处理中的文件
if (fileStatus !== "Processed" && fileStatus !== "Failed") {
toastService.warning("文件正在处理中,无法删除");
return;
}
messageService.show({
title: "确认删除",
message: `确定要删除文档"${name}"吗?`,
type: "warning",
confirmText: "删除",
cancelText: "取消",
confirmDelay: 4,
onConfirm: () => {
// 设置删除状态为true
setIsDeleting(true);
const form = new FormData();
form.append("_action", "delete");
form.append("id", id);
fetcher.submit(form, { method: "post" });
}
});
};
// 批量删除
const handleBatchDelete = () => {
if (selectedRowKeys.length === 0) {
toastService.error('请至少选择一个文档');
return;
}
// 检查是否有正在处理中的文件
const hasProcessingFiles = documents.some((doc: DocumentUI) =>
selectedRowKeys.includes(doc.id.toString()) && doc.fileStatus !== 'Processed'
);
if (hasProcessingFiles) {
toastService.error('存在服务器未处理完成的文件,请重新选择需要删除的文件');
return;
}
messageService.show({
title: "确认批量删除",
message: `确认删除选中的 ${selectedRowKeys.length} 个文档?`,
type: "warning",
confirmText: "删除",
cancelText: "取消",
confirmDelay: 4,
onConfirm: () => {
// 设置删除状态为true
setIsDeleting(true);
// 使用fetcher提交表单
const formData = new FormData();
formData.append('_action', 'batchDelete');
// 添加所有选中的ID
selectedRowKeys.forEach(id => {
formData.append('ids', id);
});
fetcher.submit(formData, { method: 'post' });
// 清空选中行
setSelectedRowKeys([]);
}
});
};
// 处理文件状态变更
const handleFileStatusChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const params = new URLSearchParams(searchParams);
if (e.target.value) {
params.set("fileStatus", e.target.value);
} else {
params.delete("fileStatus");
}
params.set("page", "1"); // 重置页码
setSearchParams(params);
};
// 导出列表
const handleExport = async () => {
// 检查是否选中了文档
if (selectedRowKeys.length === 0) {
toastService.error('请至少选择一个文档');
return;
}
// 过滤出选中的文档(仅主文档,不包括历史版本)
const selectedDocuments = documents.filter((doc: DocumentUI) =>
selectedRowKeys.includes(doc.id.toString())
);
if (selectedDocuments.length === 0) {
toastService.error('没有可导出的文档');
return;
}
try {
// 创建一个ZIP文件
const JSZip = await import('jszip').then(module => module.default);
const zip = new JSZip();
// 准备所有下载任务(仅选中的文档)
const downloadTasks = selectedDocuments.map(async (doc: DocumentUI) => {
try {
if (!doc.path) {
console.warn(`文档 ${doc.name} 没有有效的路径`);
return;
}
// 使用 PDF 代理路由获取文件,自动添加 JWT 认证
const downloadUrl = `/api/pdf-proxy?path=${encodeURIComponent(doc.path)}`;
// 获取文件内容
const response = await fetch(downloadUrl);
if (!response.ok) {
throw new Error(`下载失败: ${response.status} ${response.statusText}`);
}
// 将响应转换为Blob
const blob = await response.blob();
// 从路径中获取文件名
const fileName = doc.path.split('/').pop() || doc.name;
// 添加到ZIP文件
zip.file(decodeURIComponent(fileName), blob);
return { success: true, name: fileName };
} catch (error) {
console.error(`下载文件 ${doc.name} 失败:`, error);
return { success: false, name: doc.name, error };
}
});
// 等待所有下载任务完成
const results = await Promise.all(downloadTasks);
// 计算成功和失败的数量
const succeeded = results.filter(r => r && r.success).length;
const failed = results.filter(r => r && !r.success).length;
if (succeeded === 0) {
// alert('所有文件下载失败');
toastService.error('所有文件下载失败');
return;
}
// 生成ZIP文件
const zipBlob = await zip.generateAsync({ type: 'blob' });
// 创建下载链接
const url = URL.createObjectURL(zipBlob);
const a = document.createElement('a');
a.href = url;
a.download = `文档导出_${new Date().toISOString().slice(0, 10)}.zip`;
document.body.appendChild(a);
a.click();
// 清理
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
// 显示结果消息
if (failed > 0) {
// alert(`成功导出 ${succeeded} 个文件,${failed} 个文件失败`);
toastService.warning(`成功导出 ${succeeded} 个文件,${failed} 个文件失败`);
} else {
// alert(`成功导出 ${succeeded} 个文件`);
toastService.success(`成功导出 ${succeeded} 个文件`);
}
} catch (error) {
console.error('导出文件失败:', error);
// alert(`导出文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
toastService.error(`导出文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
};
// 开始审核
const handleReviewFileClick = async (fileId: number, auditStatus: number | null) => {
// 检查audit_status是否为0,如果是则更新为2
if (auditStatus === 0 || auditStatus === null) {
try {
// 从 localStorage 获取用户ID(与 token 管理保持一致)
const userId = getUserId();
if (!userId) {
toastService.error('用户身份验证失败');
return;
}
// console.log('开始审核',fileId,auditStatus)
const response = await updateDocumentAuditStatus(fileId.toString(), 2, userId);
if (response.error) {
console.error('更新文件审核状态失败:', response.error);
toastService.error('更新文件审核状态失败:' + (response.error || '未知错误'));
return;
}
} 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=documents`);
};
// 处理附件追加文件选择
const handleAttachmentFilesSelected = (files: FileList) => {
try {
console.log('【附件追加】开始处理附件文件选择, 文件数量:', files.length);
if (files.length > 0) {
// 检查主文件类型
const selectedDocument = documents.find(doc => doc.id === selectedDocumentId);
const isMainFileDocx = selectedDocument?.path.toLowerCase().endsWith('.docx');
// 验证文件类型,支持PDF、Word、ZIP、RAR
const validFiles: File[] = [];
let hasInvalidFiles = false;
let hasPdfForDocx = false;
Array.from(files).forEach(file => {
const fileName = file.name.toLowerCase();
const isPdf = file.type === 'application/pdf' || fileName.endsWith('.pdf');
const isValidType =
isPdf ||
// 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');
// 如果主文件是docx,不允许上传pdf附件
if (isMainFileDocx && isPdf) {
hasPdfForDocx = true;
console.error(`【附件追加】主文件为DOCX格式时不允许上传PDF附件: ${file.name}`);
return;
}
if (isValidType) {
validFiles.push(file);
} else {
hasInvalidFiles = true;
console.error(`【附件追加】无效的文件类型: ${file.name}, 类型: ${file.type}`);
}
});
if (hasPdfForDocx) {
messageService.error('主文件为DOCX格式时,附件不可以是PDF格式', {
title: '文件类型限制',
confirmText: '确定',
cancelText: '',
});
} else if (hasInvalidFiles) {
messageService.error('只支持PDF、Word、ZIP、RAR格式的文件', {
title: '文件类型错误',
confirmText: '确定',
cancelText: '',
});
}
if (validFiles.length > 0) {
setAttachmentFiles(validFiles);
console.log('【附件追加】有效文件数量:', validFiles.length);
}
}
} catch (error) {
console.error('【附件追加】处理文件选择时发生错误:', error);
}
};
// 处理附件追加上传
const handleAttachmentUpload = async () => {
if (!selectedDocumentId || attachmentFiles.length === 0) {
toastService.error('请选择文档和附件文件');
return;
}
try {
setAttachmentUploading(true);
const result = await appendContractAttachments(
selectedDocumentId,
attachmentFiles,
attachmentMergeMode,
true, // isReprocess
attachmentRemark || undefined,
loaderData.frontendJWT
);
if (result.error) {
throw new Error(result.error);
}
toastService.success('附件追加成功!');
// 重置状态
setAttachmentFiles([]);
setAttachmentRemark("");
setShowAttachmentUpload(false);
setSelectedDocumentId(null);
setSelectedDocumentName(null);
setSelectedDocumentVersion(null);
// 刷新文档列表
if (documentTypeIds && documentTypeIds.length > 0) {
fetchData(documentTypeIds);
}
} catch (error) {
console.error('【附件追加】上传失败:', error);
toastService.error(error instanceof Error ? error.message : '附件追加失败');
} finally {
setAttachmentUploading(false);
}
};
// 处理合同模板文件选择
const handleTemplateFileSelected = (files: FileList) => {
try {
console.log('【合同模板上传】开始处理模板文件选择, 文件数量:', files.length);
if (files.length > 0) {
const file = files[0];
// 验证文件类型,支持PDF和Word
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');
if (isValidType) {
setTemplateFile(file);
console.log('【合同模板上传】有效文件:', file.name);
} else {
messageService.error('只支持.pdf、.docx格式的文件', {
title: '文件类型错误',
confirmText: '确定',
cancelText: '',
});
}
}
} catch (error) {
console.error('【合同模板上传】处理文件选择时发生错误:', error);
}
};
// 处理合同模板上传
const handleTemplateUpload = async () => {
if (!selectedDocumentId || !templateFile) {
toastService.error('请选择文档和模板文件');
return;
}
try {
setTemplateUploading(true);
const result = await uploadContractTemplate(
templateFile,
selectedDocumentId,
undefined // comparisonId
);
if (result.error) {
throw new Error(result.error);
}
toastService.success('合同模板上传成功!');
// 重置状态
setTemplateFile(null);
setShowTemplateUpload(false);
setSelectedDocumentId(null);
setSelectedDocumentName(null);
setSelectedDocumentVersion(null);
// 刷新文档列表
if (documentTypeIds && documentTypeIds.length > 0) {
fetchData(documentTypeIds);
}
} catch (error) {
console.error('【合同模板上传】上传失败:', error);
toastService.error(error instanceof Error ? error.message : '合同模板上传失败');
} finally {
setTemplateUploading(false);
}
};
// 处理附件拖拽事件
const handleAttachmentDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingAttachment(true);
};
const handleAttachmentDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleAttachmentDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingAttachment(false);
};
const handleAttachmentDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingAttachment(false);
const files = e.dataTransfer.files;
if (files && files.length > 0) {
handleAttachmentFilesSelected(files);
}
};
// 处理模板拖拽事件
const handleTemplateDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingTemplate(true);
};
const handleTemplateDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleTemplateDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingTemplate(false);
};
const handleTemplateDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingTemplate(false);
const files = e.dataTransfer.files;
if (files && files.length > 0) {
handleTemplateFileSelected(files);
}
};
// 展开/折叠历史版本
const handleToggleExpand = async (doc: DocumentUI) => {
const newExpanded = new Set(expandedRows);
if (expandedRows.has(doc.id)) {
// 折叠:移除展开状态
newExpanded.delete(doc.id);
setExpandedRows(newExpanded);
// 更新展开状态
setDocuments(prevDocs =>
prevDocs.map(d =>
d.id === doc.id ? { ...d, isExpanded: false } : d
)
);
} else {
// 展开前检查是否有历史版本数据
const hasHistoryData = doc.historyVersions && doc.historyVersions.length > 0;
const hasHistoryCount = doc.historyCount && doc.historyCount > 0;
// 如果有历史版本计数但没有数据,可能是数据加载失败
if (hasHistoryCount && !hasHistoryData) {
console.warn(`文档 ${doc.id}${doc.historyCount} 个历史版本,但数据为空`);
toastService.warning('历史版本数据加载失败,请刷新页面重试');
return;
}
// 如果没有历史版本,不允许展开
if (!hasHistoryCount) {
console.log(`文档 ${doc.id} 没有历史版本`);
return;
}
// 展开:显示历史版本
newExpanded.add(doc.id);
setExpandedRows(newExpanded);
// 更新展开状态
setDocuments(prevDocs =>
prevDocs.map(d =>
d.id === doc.id ? { ...d, isExpanded: true } : d
)
);
}
};
// 渲染历史版本行的辅助函数
const renderHistoryRow = (historyDoc: DocumentVersionUI, parentDoc: DocumentUI) => {
return (
<tr key={`history-${historyDoc.id}`} className="history-row">
<td className="align-middle px-4 py-3" style={{ width: '50px' }}>
<input type="checkbox" disabled style={{ visibility: 'hidden' }} />
</td>
<td className="align-middle px-4 py-3" style={{ width: '25%' }}>
<div className="flex items-center gap-3">
<i className="ri-history-line text-gray-400 text-lg"></i>
<span className="history-version-label text-sm">
v{historyDoc.versionNumber}
</span>
<span className="history-version-label text-sm">
{historyDoc.documentNumber}
</span>
</div>
</td>
<td className="text-xs text-gray-600 px-4 py-3" style={{ width: '8%' }}>{formatFileSize(historyDoc.size)}</td>
<td className="px-4 py-3" style={{ width: '8%' }}>
{(() => {
const fileStatus = historyDoc.fileStatus || "-";
const status = fileProcessingStatusOptions.find(s => s.value === fileStatus) || fileProcessingStatusOptions[0];
const isSpinning = fileStatus !== "Processed" && fileStatus !== "Failed";
return (
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs bg-${status.color}-100 text-${status.color}-800`}>
<i className={`${status.icon} ${isSpinning ? "animate-spin" : ""} mr-1`}></i>
<span>{status.label}</span>
</div>
);
})()}
</td>
<td className="px-4 py-3" style={{ width: '8%' }}>
{(() => {
// 处理auditStatus为null或undefined的情况,默认为0(待审核)
const auditStatus = historyDoc.auditStatus != null ? historyDoc.auditStatus : 0;
const statusKey = auditStatus.toString();
const statusInfo = auditStatusMapping[statusKey] || auditStatusMapping["0"];
return (
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs bg-${statusInfo.color}-100 text-${statusInfo.color}-800`}>
<i className={`${statusInfo.icon} mr-1`}></i>
<span>{statusInfo.label}</span>
</div>
);
})()}
</td>
<td className="px-4 py-3" style={{ width: '15%' }}>
<ResultStats
passCount={historyDoc.pass_count}
warningCount={historyDoc.warning_count}
errorCount={historyDoc.error_count}
manualCount={historyDoc.manual_count}
previousPassCount={historyDoc.previous_pass_count}
previousWarningCount={historyDoc.previous_warning_count}
previousErrorCount={historyDoc.previous_error_count}
previousManualCount={historyDoc.previous_manual_count}
/>
</td>
<td className="text-xs text-gray-600 px-4 py-3" style={{ width: '10%' }}>{historyDoc.uploadTime}</td>
<td className="px-4 py-3" style={{ width: '25%' }}>
<div className="operations-cell flex flex-wrap gap-1">
{/* 查看按钮 - 需要 document:document:view 权限 */}
{canView && (
<Link
to={`/reviews?id=${historyDoc.id}&previousRoute=documents`}
className="text-xs px-2 py-1 h-7 mr-1 hover:underline"
>
<i className="ri-eye-line"></i>
</Link>
)}
{/* 修改按钮 - 需要 document:document:view 权限 */}
{canView && (
<Link
to={`/documents/edit?id=${historyDoc.id}`}
className="text-xs px-2 py-1 h-7 mr-1 text-gray-500 hover:underline hover:text-gray-700"
>
<i className="ri-edit-line"></i>
</Link>
)}
{/* 下载按钮 - 需要 document:document:view 权限 */}
{canView && (
<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(historyDoc.path)}
>
<i className="ri-download-line"></i>
</button>
)}
{/* 追加附件和上传模板按钮 - 需要 document:document:view 权限 */}
{canView && parentDoc.type === '1' && historyDoc.fileStatus === 'Processed' && (
<>
<button
type="button"
className="text-xs px-2 py-1 h-7 mr-1 hover:underline hover:text-primary"
onClick={() => {
setSelectedDocumentId(historyDoc.id);
setSelectedDocumentName(historyDoc.name);
setSelectedDocumentVersion(historyDoc.versionNumber || null);
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={() => {
setSelectedDocumentId(historyDoc.id);
setSelectedDocumentName(historyDoc.name);
setSelectedDocumentVersion(historyDoc.versionNumber || null);
setShowTemplateUpload(true);
}}
>
<i className="ri-file-copy-line"></i>
</button>
</>
)}
{/* 删除按钮 - 需要 document:document:view 权限 */}
{canView && (
<button
type="button"
className="text-xs px-2 py-1 h-7 text-error hover:underline hover:text-red-700"
onClick={() => handleDelete(historyDoc.id.toString(), historyDoc.name, historyDoc.fileStatus)}
>
<i className="ri-delete-bin-line"></i>
</button>
)}
</div>
</td>
</tr>
);
};
// 表格列定义
const columns = [
{
title: (
<input
type="checkbox"
checked={selectedRowKeys.length === documents.length}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
),
key: "selection",
width: "50px",
render: (_: unknown, record: DocumentUI) => (
<input
type="checkbox"
checked={selectedRowKeys.includes(record.id.toString())}
onChange={() => handleRowSelectionChange(record.id.toString())}
/>
)
},
{
title: "文档名称",
key: "name",
width:'25%',
render: (_: unknown, record: DocumentUI) => (
<div className="flex items-center gap-3">
{/* 展开/折叠图标(仅在有历史版本时显示) */}
{record.historyCount && record.historyCount > 0 ? (
loadingHistory.has(record.id) ? (
<i className="ri-loader-4-line expand-icon animate-spin" title="加载中..."></i>
) : (
<i
className={`ri-arrow-right-s-line expand-icon ${expandedRows.has(record.id) ? 'expanded' : ''}`}
onClick={() => handleToggleExpand(record)}
title={expandedRows.has(record.id) ? '折叠历史版本' : '展开历史版本'}
></i>
)
) : (
<span style={{ width: '20px', display: 'inline-block' }}></span>
)}
<FileTag
extension={record.fileType}
showIcon={true}
showText={false}
showBackground={false}
size="lg"
className="flex-shrink-0"
/>
<div className="flex flex-col gap-2 flex-1 min-w-0">
<span className="doc-name-text font-medium text-gray-900" title={record.name}>
{record.name}
</span>
<span className="document-number">{record.documentNumber}</span>
<div className="flex items-center gap-2 flex-wrap">
<FileTypeTag
type={record.type}
typeName={record.typeName}
text={record.typeName}
size="sm"
showIcon={false}
fileType={record.fileType}
colorMode="light"
/>
{record.isTest && (
<span className="text-xs bg-gray-100 text-gray-500 px-1 rounded"></span>
)}
{/* 版本徽章*/}
{record.historyCount !== undefined && record.historyCount > 0 ?
<span className="version-badge">
<i className="ri-history-line"></i>
v{record.historyCount + 1} {record.historyCount !== undefined && `(有${record.historyCount}个历史版本)`}
</span> : ""
}
</div>
</div>
</div>
)
},
{
title: "文件大小",
key: "size",
width: "8%",
render: (_: unknown, record: DocumentUI) => formatFileSize(record.size)
},
{
title: "文件状态",
key: "fileStatus",
width:'8%',
render: (_: unknown, record: DocumentUI) => {
// 处理fileStatus为null或undefined的情况
// console.log('fileStatus',record.fileStatus)
const fileStatus = record.fileStatus || "-";
const status = fileProcessingStatusOptions.find(s => s.value === fileStatus) ||
fileProcessingStatusOptions[0];
const isSpinning = fileStatus !== "Processed" && fileStatus !== "Failed";
return (
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs bg-${status.color}-100 text-${status.color}-800`}>
<i className={`${status.icon} ${isSpinning ? "animate-spin" : ""} mr-1`}></i>
<span>{status.label}</span>
</div>
);
}
},
{
title: "审核状态",
key: "auditStatus",
width:"8%",
render: (_: unknown, record: DocumentUI) => {
// 处理auditStatus为null或undefined的情况,默认为0(待审核)
const auditStatus = record.auditStatus != null ? record.auditStatus : 0;
const statusKey = auditStatus.toString();
const statusInfo = auditStatusMapping[statusKey] || auditStatusMapping["0"];
return (
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs bg-${statusInfo.color}-100 text-${statusInfo.color}-800`}>
<i className={`${statusInfo.icon} mr-1`}></i>
<span>{statusInfo.label}</span>
</div>
);
}
},
{
title: "结果统计",
key: "issues",
width:"18%",
render: (_: unknown, record: DocumentUI) => (
<ResultStats
passCount={record.pass_count}
warningCount={record.warning_count}
errorCount={record.error_count}
manualCount={record.manual_count}
previousPassCount={record.previous_pass_count}
previousWarningCount={record.previous_warning_count}
previousErrorCount={record.previous_error_count}
previousManualCount={record.previous_manual_count}
warningMessages={record.warning_messages}
errorMessages={record.error_messages}
manualMessages={record.manual_messages}
/>
)
},
{
title: "上传时间",
key: "uploadTime",
width:"8%",
render: (_: unknown, record: DocumentUI) => record.uploadTime
},
{
title: "操作",
key: "actions",
width: "25%",
render: (_: unknown, record: DocumentUI) => (
<div className="operations-cell flex flex-wrap gap-1">
{/* 查看/开始审核按钮 - 需要 document:document:view 权限 */}
{canView && (
<>
{(record.auditStatus === 0 || record.auditStatus == null) ? (
<button
onClick={() => handleReviewFileClick(record.id, record.auditStatus)}
disabled={record.fileStatus !== 'Processed'}
className={`text-xs px-2 py-1 h-7 mr-1 ${
record.fileStatus === 'Processed'
? 'hover:underline hover:cursor-pointer text-primary'
: 'text-gray-400 cursor-not-allowed opacity-60'
}`}
>
<i className="ri-play-circle-line"></i>
</button>
) : record.auditStatus === 3 ? (
//record.auditStatus === 3 目前这个状态不存在,所以除了待审核(0)-开始审核,其他都是审核中(2)-查看
<Link
to={`/documents/${record.id}/progress`}
className="text-xs px-2 py-1 h-7 mr-1 hover:underline"
>
<i className="ri-eye-line"></i>
</Link>
) : (
<Link
to={`/reviews?id=${record.id}&previousRoute=documents`}
className="text-xs px-2 py-1 h-7 mr-1 hover:underline"
>
<i className="ri-eye-line"></i>
</Link>
)}
</>
)}
{/* 修改按钮 - 需要 document:document:view 权限 */}
{canView && (
<Link
to={`/documents/edit?id=${record.id}`}
className="text-xs px-2 py-1 h-7 mr-1 text-gray-500 hover:underline hover:text-gray-700"
>
<i className="ri-edit-line"></i>
</Link>
)}
{/* 下载按钮 - 需要 document:document:view 权限 */}
{canView && (
<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(record.path)}
>
<i className="ri-download-line"></i>
</button>
)}
{/* 追加附件和上传模板按钮 - 需要 document:document:view 权限 */}
{canView && record.type === '1' && record.fileStatus === 'Processed' && (
<>
<button
type="button"
className="text-xs px-2 py-1 h-7 mr-1 hover:underline hover:text-primary"
onClick={() => {
setSelectedDocumentId(record.id);
setSelectedDocumentName(record.name);
setSelectedDocumentVersion(record.historyCount !== undefined && record.historyCount > 0 ? record.historyCount + 1 : null);
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={() => {
setSelectedDocumentId(record.id);
setSelectedDocumentName(record.name);
setSelectedDocumentVersion(record.historyCount !== undefined && record.historyCount > 0 ? record.historyCount + 1 : null);
setShowTemplateUpload(true);
}}
>
<i className="ri-file-copy-line"></i>
</button>
</>
)}
{/* 删除按钮 - 需要 document:document:view 权限 */}
{canView && (
<button
type="button"
className="text-xs px-2 py-1 h-7 text-error hover:underline hover:text-red-700"
onClick={() => handleDelete(record.id.toString(), record.name, record.fileStatus)}
>
<i className="ri-delete-bin-line"></i>
</button>
)}
</div>
)
}
];
return (
<div className="documents-page relative">
{/* 页面内容,在加载时降低不透明度但不隐藏内容 */}
<div className={isLoadingData ? "opacity-70 pointer-events-none transition-opacity" : "transition-opacity"}>
{/* 页面头部 */}
<div className="flex justify-between items-center mb-4">
<div className="flex items-center">
<h2 className="text-xl font-medium"></h2>
{!hasInitialized || isLoadingData ? (
<div className="ml-4">
<NumberSkeleton />
</div>
) : (
<div className="ml-4 text-sm text-secondary">
<span className="font-medium text-primary">{total}</span>
</div>
)}
</div>
<div>
<Button
type="primary"
icon="ri-upload-line"
to="/files/upload"
className="hover:text-white"
>
</Button>
</div>
</div>
{/* 搜索筛选区 */}
<FilterPanel
actions={
<>
<Button
type="primary"
icon="ri-search-line"
onClick={() => {
if (documentTypeIds) {
fetchData(documentTypeIds);
}
}}
className="mr-2"
>
</Button>
<Button
type="default"
icon="ri-refresh-line"
onClick={handleReset}
className="mr-2"
>
</Button>
</>
}
noActionDivider={true}
>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 w-full">
<SearchFilter
label="文档名称"
placeholder="请输入文档名称"
value={search}
onSearch={handleNameSearch}
instantSearch={true}
/>
<SearchFilter
label="文档编号"
placeholder="请输入文档编号"
value={documentNumber}
onSearch={handleDocumentNumberChange}
instantSearch={true}
/>
<FilterSelect
label="文档类型"
name="documentType"
value={documentType}
options={filteredDocumentTypeOptions}
onChange={handleDocumentTypeChange}
/>
<FilterSelect
label="文件状态"
name="fileStatus"
value={fileStatus}
options={fileStatusOptions}
onChange={handleFileStatusChange}
/>
<FilterSelect
label="审核状态"
name="auditStatus"
value={auditStatus}
options={auditStatusOptions}
onChange={handleStatusChange}
/>
<DateRangeFilter
label="上传时间"
startDate={dateFrom}
endDate={dateTo}
onStartDateChange={(value) => handleDateChange('dateFrom', value)}
onEndDateChange={(value) => handleDateChange('dateTo', value)}
simple={true}
colorMode="light"
/>
</div>
</FilterPanel>
{/* 数据表格 */}
<Card>
{isLoadingData && <LoadingIndicator />}
<div className="mb-3 flex items-center justify-between">
<div>
<Button
type="default"
icon="ri-delete-bin-line"
onClick={handleBatchDelete}
className="mr-2"
disabled={selectedRowKeys.length === 0}
>
</Button>
<Button
type="default"
icon="ri-download-line"
onClick={handleExport}
>
</Button>
</div>
<div className="text-sm text-secondary">
<span className="font-medium text-primary">{total}</span>
</div>
</div>
<div className="overflow-x-auto">
{/* 未初始化完成时显示骨架屏,初始化完成后根据数据显示内容或"暂无数据" */}
{!hasInitialized || (isLoadingData && documents.length === 0) ? (
<TableRowSkeleton count={5} />
) : documents.length === 0 ? (
<div className="text-center py-8 text-gray-500">
</div>
) : (
<table className="w-full border-collapse">
<thead className="bg-gray-50">
<tr>
{columns.map((col, index) => (
<th
key={col.key || index}
style={{ width: col.width }}
className="px-4 py-3 text-left text-sm font-semibold text-gray-700 border-b"
>
{col.title}
</th>
))}
</tr>
</thead>
<tbody>
{documents.map((doc) => (
<>
{/* 主文档行 */}
<tr
key={doc.id}
className={`border-b hover:bg-gray-50 transition-colors ${
doc.historyCount && doc.historyCount > 0 ? 'cursor-pointer' : ''
}`}
onClick={(e) => {
// 只有有历史版本的行才可以点击
if (!doc.historyCount || doc.historyCount === 0) return;
// 检查点击的是否是可交互元素(链接、按钮、输入框等)
const target = e.target as HTMLElement;
const isInteractiveElement =
target.tagName === 'A' ||
target.tagName === 'BUTTON' ||
target.tagName === 'INPUT' ||
target.closest('a') ||
target.closest('button') ||
target.closest('input') ||
target.closest('.result-stats-wrapper') ||
target.closest('.result-stat-item');
// 如果点击的是可交互元素,不触发展开/收起
if (isInteractiveElement) return;
// 触发展开/收起
handleToggleExpand(doc);
}}
>
{columns.map((col, index) => (
<td key={col.key || index} className="px-4 py-3 text-sm">
{col.render ? col.render(null, doc, index) : (doc as any)[col.key]}
</td>
))}
</tr>
{/* 历史版本行 */}
{doc.isExpanded && (
<>
{/* 正在加载历史版本 */}
{loadingHistory.has(doc.id) ? (
<tr key={`loading-${doc.id}`} className="history-row">
<td colSpan={columns.length} className="px-4 py-3">
<div className="version-loading">
<i className="ri-loader-4-line"></i>
...
</div>
</td>
</tr>
) : doc.historyVersions && doc.historyVersions.length > 0 ? (
/* 显示历史版本数据 */
doc.historyVersions.map((historyDoc) => renderHistoryRow(historyDoc, doc))
) : (
/* 数据为空时的提示 */
<tr key={`empty-${doc.id}`} className="history-row">
<td colSpan={columns.length} className="px-4 py-3">
<div className="text-center text-gray-500 text-sm py-2">
<i className="ri-information-line mr-1"></i>
</div>
</td>
</tr>
)}
</>
)}
</>
))}
</tbody>
</table>
)}
</div>
{/* 分页 */}
<Pagination
currentPage={currentPage}
total={total}
pageSize={pageSize}
onChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
pageSizeOptions={[10, 20, 50, 100]}
/>
</Card>
</div>
{/* 附件追加模态框 */}
{showAttachmentUpload && (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]"
onClick={() => {
setShowAttachmentUpload(false);
setSelectedDocumentId(null);
setSelectedDocumentName(null);
setSelectedDocumentVersion(null);
setAttachmentFiles([]);
setAttachmentRemark("");
}}
>
<div
className="bg-white rounded-lg p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold"></h3>
<button
onClick={() => {
setShowAttachmentUpload(false);
setSelectedDocumentId(null);
setSelectedDocumentName(null);
setSelectedDocumentVersion(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> */}
: <span className="font-medium">{selectedDocumentName}</span>
{selectedDocumentVersion && (
<span className="ml-2 text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
v{selectedDocumentVersion}
</span>
)}
</p>
<p className="text-xs text-gray-500 mt-1">
.pdf.docxZIPRAR格式
<i className="ri-information-line mr-1"></i>
ZIP/RAR内需要保证文件格式一致
</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 rounded-lg p-6 text-center transition-colors ${
isDraggingAttachment
? 'border-primary bg-primary-light'
: 'border-gray-300 hover:border-gray-400'
}`}
onDragEnter={handleAttachmentDragEnter}
onDragOver={handleAttachmentDragOver}
onDragLeave={handleAttachmentDragLeave}
onDrop={handleAttachmentDrop}
>
<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 mb-2 block ${isDraggingAttachment ? 'text-primary' : 'text-gray-400'}`}></i>
<p className={`text-sm ${isDraggingAttachment ? 'text-primary font-medium' : 'text-gray-600'}`}>
{isDraggingAttachment ? '松开鼠标上传文件' : '点击选择文件或拖拽文件到此处'}
</p>
<p className="text-xs text-gray-500 mt-1">.pdf.docx.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);
setSelectedDocumentName(null);
setSelectedDocumentVersion(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>
)}
{/* 合同模板上传模态框 */}
{showTemplateUpload && (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]"
onClick={() => {
setShowTemplateUpload(false);
setSelectedDocumentId(null);
setSelectedDocumentName(null);
setSelectedDocumentVersion(null);
setTemplateFile(null);
}}
>
<div
className="bg-white rounded-lg p-6 w-full max-w-xl mx-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold"></h3>
<button
onClick={() => {
setShowTemplateUpload(false);
setSelectedDocumentId(null);
setSelectedDocumentName(null);
setSelectedDocumentVersion(null);
setTemplateFile(null);
}}
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> */}
: <span className="font-medium">{selectedDocumentName}</span>
{selectedDocumentVersion && (
<span className="ml-2 text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
v{selectedDocumentVersion}
</span>
)}
</p>
<p className="text-xs text-gray-500 mt-1">
.pdf.docx格式
</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 rounded-lg p-6 text-center transition-colors ${
isDraggingTemplate
? 'border-primary bg-primary-light'
: 'border-gray-300 hover:border-gray-400'
}`}
onDragEnter={handleTemplateDragEnter}
onDragOver={handleTemplateDragOver}
onDragLeave={handleTemplateDragLeave}
onDrop={handleTemplateDrop}
>
<input
type="file"
accept=".pdf,.docx"
onChange={(e) => e.target.files && handleTemplateFileSelected(e.target.files)}
className="hidden"
id="template-file-input"
/>
<label htmlFor="template-file-input" className="cursor-pointer">
<i className={`ri-file-copy-line text-3xl mb-2 block ${isDraggingTemplate ? 'text-primary' : 'text-gray-400'}`}></i>
<p className={`text-sm ${isDraggingTemplate ? 'text-primary font-medium' : 'text-gray-600'}`}>
{isDraggingTemplate ? '松开鼠标上传文件' : '点击选择文件或拖拽文件到此处'}
</p>
<p className="text-xs text-gray-500 mt-1">.pdf.docx格式</p>
</label>
</div>
{templateFile && (
<div className="mt-2">
<p className="text-sm text-green-600 mb-2">
<i className="ri-checkbox-circle-line"></i>
</p>
<div className="text-xs text-gray-600 bg-gray-50 p-2 rounded">
<i className="ri-file-line mr-1"></i>
{templateFile.name} ({formatFileSize(templateFile.size)})
</div>
</div>
)}
</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={() => {
setShowTemplateUpload(false);
setSelectedDocumentId(null);
setSelectedDocumentName(null);
setSelectedDocumentVersion(null);
setTemplateFile(null);
}}
disabled={templateUploading}
>
</button>
<button
className="px-4 py-2 text-sm bg-primary text-white rounded-md hover:bg-primary-dark disabled:opacity-50"
onClick={handleTemplateUpload}
disabled={!templateFile || templateUploading}
>
{templateUploading ? '上传中...' : '开始上传'}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
// 错误边界处理
export function ErrorBoundary() {
return (
<div className="error-container">
<h1 className="text-xl font-bold text-red-500"></h1>
<p></p>
</div>
);
}