Files
leaudit-platform-frontend/app/routes/documents.list.tsx
T
LiangShiyong 2edde8a8ab feat: 1. 完善全局路由的访问权限的验证。 2. 完善接口返回的树形路由结构 3.优化评查点列表的查询,改用表连接的方式,废弃使用数据库的rpc函数,同时进行地区隔离和权限隔离。
4. 删除冗余的评查文件列表。      5.完善上传文档 页面初始化查询数据的时候 查询文件类型(改成动态指定)  6. 添加获取入口模块的查询接口。    7.完善服务端中判断token的有效性,失效则跳转到登录页。
8. 重构layout和sidebar的页面,改成由动态权限路由来渲染对应的菜单栏。       9.重构入口页面,通过动态查询根据不同地区的人返回不同的入口。
2025-11-20 01:35:30 +08:00

1778 lines
64 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 { 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 { getDocuments, getDocumentsWithVersionInfo, getDocumentHistory, deleteDocument, type DocumentUI, type DocumentVersionUI } from "~/api/files/documents";
import { IssuesDiff } from "~/components/ui/IssuesDiff";
import { getDocumentTypes } from "~/api/document-types/document-types";
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({ pageSize: 500 }, 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();
// 存储从 sessionStorage 获取的 reviewType
const [reviewType, setReviewType] = useState<string | null>(null);
// 添加页面加载状态管理
const [isLoadingData, setIsLoadingData] = useState(true);
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 [attachmentFiles, setAttachmentFiles] = useState<File[]>([]);
const [templateFile, setTemplateFile] = useState<File | null>(null);
const [attachmentMergeMode, setAttachmentMergeMode] = useState<'overwrite' | 'new'>('overwrite');
const [attachmentRemark, setAttachmentRemark] = useState<string>("");
const [attachmentUploading, setAttachmentUploading] = useState<boolean>(false);
const [templateUploading, setTemplateUploading] = 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 (storedReviewType: string) => {
setIsLoadingData(true);
loadingBarService.show();
try {
// 从 localStorage 获取用户ID(与 token 管理保持一致)
const userId = getUserId();
if (!userId) {
toastService.error('用户身份验证失败,无法获取文档列表');
setIsLoadingData(false);
loadingBarService.hide();
return;
}
// 构建搜索参数(token 由 axios 拦截器自动从 localStorage 获取)
const searchParams = {
name: search || undefined,
documentNumber: documentNumber || undefined,
documentType: documentType || undefined,
auditStatus: auditStatus || undefined,
fileStatus: fileStatus || undefined,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
reviewType: storedReviewType || undefined,
userId: userId, // 添加用户ID筛选
page: currentPage,
pageSize
};
// 获取文档列表(带版本信息)
const documentsResponse = await getDocumentsWithVersionInfo(searchParams);
if (documentsResponse.error) {
throw new Error(documentsResponse.error);
}
// 🔑 从 sessionStorage 读取文档类型 IDs
const typeIdsStr = sessionStorage.getItem('documentTypeIds');
const documentTypeIds = typeIdsStr ? JSON.parse(typeIdsStr) : undefined;
// 获取经过过滤的文档类型列表(token 由 axios 拦截器自动获取)
const filteredTypesResponse = await getDocumentTypes({
pageSize: 500,
documentTypeIds: documentTypeIds // 使用动态的文档类型 IDs
});
const filteredDocumentTypes = filteredTypesResponse.data?.types || [];
const filteredOptions = filteredDocumentTypes.map(type => ({
value: type.id,
label: type.name
}));
// console.log('文档列表',documentsResponse)
// 更新状态
setDocuments(documentsResponse.data?.documents || []);
setTotal(documentsResponse.data?.total || 0);
setFilteredDocumentTypeOptions(filteredOptions);
} catch (error) {
console.error('获取文档列表失败:', error);
toastService.error('获取文档列表失败: ' + (error instanceof Error ? error.message : '未知错误'));
} finally {
setIsLoadingData(false);
loadingBarService.hide();
}
}, [search, documentNumber, documentType, auditStatus, fileStatus, dateFrom, dateTo, currentPage, pageSize, getUserId]);
// 在组件挂载时从 sessionStorage 获取 reviewType 并加载数据
useEffect(() => {
try {
if (typeof window !== 'undefined') {
const storedReviewType = sessionStorage.getItem('reviewType');
setReviewType(storedReviewType);
// 如果有 reviewType,则加载数据
if (storedReviewType) {
fetchData(storedReviewType);
}
}
} catch (error) {
console.error('获取 sessionStorage 中的 reviewType 失败:', error);
}
}, [fetchData]);
// 监听 URL 参数变化,重新获取数据
useEffect(() => {
if (reviewType) {
fetchData(reviewType);
}
}, [searchParams, fetchData, reviewType]);
// 使用并更新缓存数据
useEffect(() => {
// 如果有缓存数据,先显示缓存,再在后台加载新数据
if (dataCache.current) {
setIsLoadingData(false);
} else {
// 显示加载状态 - 确保显示加载条
loadingBarService.show();
setIsLoadingData(true);
}
// 设置缓存数据
dataCache.current = loaderData;
// 处理loader错误
if (loaderData.error) {
toastService.error(loaderData.error);
}
}, [loaderData]);
// 使用useEffect监听fetcher状态变化并显示Toast
useEffect(() => {
if (fetcher.data && fetcher.state === 'idle' && isDeleting) {
// 重置删除状态
setIsDeleting(false);
if (fetcher.data.result) {
toastService.success(fetcher.data.message);
// 删除成功后重新加载数据
if (reviewType) {
fetchData(reviewType);
}
} else if (fetcher.data.message) {
toastService.error(fetcher.data.message);
// 删除失败只显示错误信息,不刷新数据
}
}
}, [fetcher.data, fetcher.state, fetchData, reviewType, 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 = async (path: string) => {
try {
// 使用 PDF 代理路由获取文件,自动添加 JWT 认证
const downloadUrl = `/api/pdf-proxy?path=${encodeURIComponent(path)}`;
// 使用fetch获取文件内容
const response = await fetch(downloadUrl);
if (!response.ok) {
throw new Error(`下载失败: ${response.status} ${response.statusText}`);
}
// 将响应转换为Blob
const blob = await response.blob();
// 创建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 handleDelete = (id: string, name: string, fileStatus: string) => {
// 禁止删除处理中的文件
if (fileStatus !== "Processed" && fileStatus !== "Failed") {
toastService.warning("文件正在处理中,无法删除");
return;
}
messageService.show({
title: "确认删除",
message: `确定要删除文档"${name}"吗?`,
type: "warning",
confirmText: "删除",
cancelText: "取消",
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: "取消",
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 (documents.length === 0) {
// alert('当前页面没有文档可供导出');
toastService.error('当前页面没有文档可供导出');
return;
}
try {
// 创建一个ZIP文件
const JSZip = await import('jszip').then(module => module.default);
const zip = new JSZip();
// 准备所有下载任务
const downloadTasks = documents.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) {
// 验证文件类型,支持PDF、Word、ZIP、RAR
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;
console.error(`【附件追加】无效的文件类型: ${file.name}, 类型: ${file.type}`);
}
});
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);
// 刷新文档列表
if (reviewType) {
fetchData(reviewType);
}
} 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、Word格式的文件', {
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);
// 刷新文档列表
if (reviewType) {
fetchData(reviewType);
}
} catch (error) {
console.error('【合同模板上传】上传失败:', error);
toastService.error(error instanceof Error ? error.message : '合同模板上传失败');
} finally {
setTemplateUploading(false);
}
};
// 展开/折叠历史版本
const handleToggleExpand = async (doc: DocumentUI) => {
const newExpanded = new Set(expandedRows);
const newLoading = new Set(loadingHistory);
if (expandedRows.has(doc.id)) {
// 折叠:移除展开状态
newExpanded.delete(doc.id);
setExpandedRows(newExpanded);
// 清空历史版本数据
setDocuments(prevDocs =>
prevDocs.map(d =>
d.id === doc.id ? { ...d, historyVersions: undefined, isExpanded: false } : d
)
);
} else {
// 展开:加载历史版本
newExpanded.add(doc.id);
setExpandedRows(newExpanded);
// 如果还没有加载历史版本,则加载
if (!doc.historyVersions && doc.historyCount && doc.historyCount > 0) {
newLoading.add(doc.id);
setLoadingHistory(newLoading);
try {
// 从 localStorage 获取用户ID(与 token 管理保持一致)
const userId = getUserId();
if (!userId) {
toastService.error('用户身份验证失败');
newExpanded.delete(doc.id);
setExpandedRows(newExpanded);
newLoading.delete(doc.id);
setLoadingHistory(newLoading);
return;
}
const result = await getDocumentHistory(
doc.name,
userId,
doc.id
);
if (result.data) {
// 更新文档的历史版本数据
setDocuments(prevDocs =>
prevDocs.map(d =>
d.id === doc.id
? { ...d, historyVersions: result.data, isExpanded: true }
: d
)
);
} else if (result.error) {
toastService.error(`加载历史版本失败: ${result.error}`);
// 加载失败时取消展开
newExpanded.delete(doc.id);
setExpandedRows(newExpanded);
}
} catch (error) {
console.error('加载历史版本失败:', error);
toastService.error('加载历史版本失败');
newExpanded.delete(doc.id);
setExpandedRows(newExpanded);
} finally {
newLoading.delete(doc.id);
setLoadingHistory(newLoading);
}
} else {
// 已经加载过,只更新展开状态
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">
<input type="checkbox" disabled style={{ visibility: 'hidden' }} />
</td>
<td className="align-middle">
<div className="flex items-center justify-center gap-3">
<i className="ri-history-line text-gray-400 text-lg"></i>
<span className="history-version-label">
v{historyDoc.versionNumber}
</span>
</div>
</td>
<td className="text-xs text-gray-600 px-4">{historyDoc.documentNumber}</td>
<td className="text-xs text-gray-600 px-4">{formatFileSize(historyDoc.size)}</td>
<td className="px-4">
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs bg-green-100 text-green-800`}>
<i className="ri-check-line mr-1"></i>
<span>{fileProcessingStatusOptions.find(s => s.value === historyDoc.fileStatus)?.label || '已完成'}</span>
</div>
</td>
<td className="px-4">
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs ${
historyDoc.auditStatus === 1 ? 'bg-green-100 text-green-800' :
historyDoc.auditStatus === -1 ? 'bg-red-100 text-red-800' :
'bg-blue-100 text-blue-800'
}`}>
<i className={`${auditStatusMapping[historyDoc.auditStatus]?.icon || 'ri-time-line'} mr-1`}></i>
<span>{auditStatusMapping[historyDoc.auditStatus]?.label || '待审核'}</span>
</div>
</td>
<td className="px-4">
<IssuesDiff
currentIssues={historyDoc.issues}
previousIssues={undefined}
issuesDiff={historyDoc.issuesDiff}
issuesDiffType={historyDoc.issuesDiffType}
/>
</td>
<td className="text-xs text-gray-600 px-4">{historyDoc.uploadTime}</td>
<td className="">
<div className="operations-cell flex flex-wrap gap-1 px-4">
<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>
<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>
<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>
<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>
<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: "documentNumber",
width:'10%',
render: (_: unknown, record: DocumentUI) => (
<span className="document-number">{record.documentNumber}</span>
)
},
{
title: "文件大小",
key: "size",
width: "10%",
render: (_: unknown, record: DocumentUI) => formatFileSize(record.size)
},
{
title: "文件状态",
key: "fileStatus",
width:'10%',
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:"10%",
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:"10%",
render: (_: unknown, record: DocumentUI) => (
<IssuesDiff
currentIssues={record.issues}
previousIssues={record.previousIssues}
issuesDiff={
record.issues != null && record.previousIssues != null
? Math.abs(record.issues - record.previousIssues)
: undefined
}
issuesDiffType={
record.issues != null && record.previousIssues != null
? record.issues > record.previousIssues
? 'increase'
: record.issues < record.previousIssues
? 'decrease'
: 'same'
: undefined
}
/>
)
},
{
title: "上传时间",
key: "uploadTime",
width:"10%",
render: (_: unknown, record: DocumentUI) => record.uploadTime
},
{
title: "操作",
key: "actions",
width: "25%",
render: (_: unknown, record: DocumentUI) => (
<div className="operations-cell flex flex-wrap gap-1">
{(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>
)}
<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>
<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>
{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);
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);
setShowTemplateUpload(true);
}}
>
<i className="ri-file-copy-line"></i>
</button>
</>
)}
<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>
{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="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">
{isLoadingData && documents.length === 0 ? (
<TableRowSkeleton count={5} />
) : documents.length === 0 ? (
<div className="text-center py-8 text-gray-500">
{isLoadingData ? "加载中..." : "暂无数据"}
</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">
{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 && doc.historyVersions && doc.historyVersions.length > 0 && (
<>
{doc.historyVersions.map((historyDoc) => renderHistoryRow(historyDoc, doc))}
</>
)}
{/* 正在加载历史版本 */}
{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>
)}
</>
))}
</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-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>
)}
{/* 合同模板上传模态框 */}
{showTemplateUpload && (
<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-lg mx-4">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold"></h3>
<button
onClick={() => {
setShowTemplateUpload(false);
setSelectedDocumentId(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>
</p>
<p className="text-xs text-gray-500 mt-1">
PDFWord格式
</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"
accept=".pdf,.doc,.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 text-gray-400 mb-2 block"></i>
<p className="text-sm text-gray-600"></p>
<p className="text-xs text-gray-500 mt-1">PDFWord格式</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);
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>
);
}