import React, { useState, useRef, useEffect } from "react"; import { type MetaFunction, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node"; import { useNavigate, useLoaderData } from "@remix-run/react"; import { UploadArea, type UploadAreaRef } from "~/components/ui/UploadArea"; import { Button } from "~/components/ui/Button"; import { messageService } from "~/components/ui/MessageModal"; import { toastService } from "~/components/ui/Toast"; import crossCheckingUploadStyles from "~/styles/pages/cross-checking-upload.css?url"; import MultiCascader from "~/components/ui/MultiCascader"; import { SingleDatePicker, links as dateRangePickerLinks } from "~/components/ui/DateRangePicker"; import { CaseType, CASE_TYPE_TO_TYPE_ID, type CrossCheckingUploadedFile, generateFileId, formatFileSize, uploadCrossCheckingDocument, createCrossReviewTask } from "~/api/cross-checking/cross-files-upload"; import { getCrossCheckingDocumentTypes, type DocumentType } from "~/api/cross-checking/cross-files"; import { getOrganizationTree, convertToTreeData, extractUsersFromNode, convertUserToTreeNode, convertSearchResultsToTreeNodes, searchUsers, type TreeNodeItem, type UserInfo } from "~/api/user/user-management"; import { API_BASE_URL } from '~/config/api-config'; import { CONTRACT_TYPES, DEFAULT_CONTRACT_TYPE } from '~/constants/contractTypes'; export const meta: MetaFunction = () => { return [ { title: "交叉评查上传 - 中国烟草AI合同及卷宗审核系统" }, { name: "description", content: "交叉评查案卷上传和任务创建" } ]; }; export const handle = { breadcrumb: "交叉评查上传" }; export function links() { return [ { rel: "stylesheet", href: crossCheckingUploadStyles }, ...dateRangePickerLinks() ]; } // 步骤枚举 const STEPS = [ { id: 1, label: "创建任务" }, { id: 2, label: "创建评查小组" }, { id: 3, label: "选择卷宗" } ]; // 默认的空组织架构数据(作为备用) const DEFAULT_TREE: TreeNodeItem[] = []; // 用户选择状态管理 interface UserSelectionState { treeData: TreeNodeItem[]; loading: boolean; error: string | null; } // 获取用户完整信息 const getUserFullName = (value: string, treeData: TreeNodeItem[]): string => { for (const node of treeData) { if (node.value === value && node.isUser) { return `${node.userInfo?.nick_name || node.label} (${node.userInfo?.tenant_name} · ${node.userInfo?.ou_name})`; } if (node.children) { const found = getUserFullName(value, node.children); if (found) return found; } } return value; }; // 从用户ID获取用户信息(优先从搜索用户Map中查找,再从树数据中查找) const getUserInfoById = ( userId: string, treeData: TreeNodeItem[], searchedUsersMap?: Map ): UserInfo | null => { // 先从搜索用户Map中查找 if (searchedUsersMap && searchedUsersMap.has(userId)) { return searchedUsersMap.get(userId)!; } // 再从树数据中查找 for (const node of treeData) { if (node.value === userId && node.isUser && node.userInfo) { return node.userInfo; } if (node.children) { const found = getUserInfoById(userId, node.children, searchedUsersMap); if (found) return found; } } return null; }; /** * 获取用户会话和前端JWT,以及文档类型列表 */ export const loader = async ({ request }: LoaderFunctionArgs) => { // 获取用户会话信息 const { getUserSession } = await import("~/api/login/auth.server"); const { userInfo, frontendJWT } = await getUserSession(request); // 获取可用于交叉评查的文档类型列表 const documentTypesResponse = await getCrossCheckingDocumentTypes(frontendJWT); return Response.json({ userInfo, frontendJWT, documentTypes: documentTypesResponse.success ? documentTypesResponse.data : [], documentTypesError: documentTypesResponse.error }); }; export const action = async ({ request }: ActionFunctionArgs) => { const formData = await request.formData(); const caseType = formData.get("caseType") as string; const uploadType = formData.get("uploadType") as string; // console.log("交叉评查上传:", { caseType, uploadType }); // 这里可以处理上传后的业务逻辑 // 例如创建任务记录等 return Response.json({ success: true, message: "文件上传成功" }); }; export default function CrossCheckingUpload() { // 获取loader数据 const { userInfo, frontendJWT, documentTypes, documentTypesError } = useLoaderData(); // 基础状态 - 使用第一个文档类型的ID作为默认值 const [selectedDocTypeId, setSelectedDocTypeId] = useState( documentTypes && documentTypes.length > 0 ? documentTypes[0].id : null ); // 步骤状态 const [currentStep, setCurrentStep] = useState(1); // 任务创建状态 const [isCreatingTask, setIsCreatingTask] = useState(false); // 步骤1:任务信息 const [taskInfo, setTaskInfo] = useState({ name: '', date: '', type: 'CITY', // 使用枚举值,默认为市局间交叉评查 }); // 步骤2状态 // 当前用户ID(用于标识主要负责人,不可取消勾选) const currentUserId = userInfo?.user_id ? `user_${userInfo.user_id}` : null; const [groupChecked, setGroupChecked] = useState(currentUserId ? [currentUserId] : []); const [leaderIds, setLeaderIds] = useState([]); // 额外的负责人ID数组(不包含当前用户) const [userSelectionState, setUserSelectionState] = useState({ treeData: DEFAULT_TREE, loading: false, error: null }); // 存储从搜索结果添加的用户信息(这些用户不在 treeData 中) const [searchedUsers, setSearchedUsers] = useState>(new Map()); // 上传配置状态 - 设置默认值 const [priority] = useState("normal"); const [documentNumber] = useState(""); const [remark] = useState(""); const [isTestDocument] = useState(false); const [attributeType, setAttributeType] = useState(DEFAULT_CONTRACT_TYPE); // 文件管理状态 - 简化为单文件上传 const [uploadedFile, setUploadedFile] = useState(null); const [isUploading, setIsUploading] = useState(false); // 引用 const uploadRef = useRef(null); // 处理案卷类型切换 const handleDocTypeChange = (docTypeId: number) => { if (isUploading) { toastService.warning("上传进行中,无法切换案卷类型"); return; } setSelectedDocTypeId(docTypeId); // 清空已选择的文件和重置上传方式 clearAllFiles(); const selectedType = documentTypes?.find((dt: DocumentType) => dt.id === docTypeId); // console.log("案卷类型切换为:", selectedType?.name, "ID:", docTypeId); }; // 清空文件 const clearAllFiles = () => { setUploadedFile(null); uploadRef.current?.resetFileInput(); }; // 获取文件类型信息 const getFileTypeInfo = (file: File) => { const fileName = file.name.toLowerCase(); const isPdf = file.type === 'application/pdf' || fileName.endsWith('.pdf'); const isDocx = file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || fileName.endsWith('.docx'); const isZip = file.type === 'application/zip' || file.type === 'application/x-zip-compressed' || fileName.endsWith('.zip'); const is7z = file.type === 'application/x-7z-compressed' || fileName.endsWith('.7z'); return { isPdf, isDocx, isZip, is7z }; }; // 处理文件选择(合并单案件和多案件) const handleFileSelected = (files: FileList) => { if (files.length === 0) return; // 只取第一个文件 const file = files[0]; const { isPdf, isDocx, isZip, is7z } = getFileTypeInfo(file); // 验证文件类型 if (!isPdf && !isDocx && !isZip && !is7z) { messageService.error('只能上传 PDF、DOCX 文件或 ZIP、7Z 压缩包', { title: '文件类型错误', confirmText: '确定', }); return; } // 确定文件上传类型 const uploadType: 'single' | 'multiple' = (isZip || is7z) ? 'multiple' : 'single'; setUploadedFile({ id: generateFileId(), file, name: file.name, size: file.size, type: file.type, uploadType }); // console.log("选择文件:", file.name, "类型:", uploadType); }; // 删除文件 const handleRemoveFile = () => { if (isUploading) { toastService.warning("上传进行中,无法删除文件"); return; } clearAllFiles(); }; /** * 处理创建任务 */ const handleCreateTask = async () => { // 验证步骤1:任务信息 if (!taskInfo.name.trim()) { toastService.error("请填写任务名称"); return; } if (!taskInfo.date.trim()) { toastService.error("请选择任务日期"); return; } // 验证步骤2:评查小组 if (groupChecked.length === 0) { toastService.error("请选择评查小组成员"); return; } // 验证步骤3:文件上传 if (!uploadedFile) { toastService.error("请先选择要上传的文件"); return; } const filesToUpload = [uploadedFile]; // 验证选择了案卷类型 if (!selectedDocTypeId) { toastService.error("请选择案卷类型"); return; } setIsCreatingTask(true); setIsUploading(true); try { // 获取选中的文档类型信息 const selectedDocType = documentTypes?.find((dt: DocumentType) => dt.id === selectedDocTypeId); if (!selectedDocType) { toastService.error("无效的案卷类型"); return; } // 第一步:先上传文档到平台,再用 v3 接口创建交叉评查任务 // 提取用户ID(从选中的组织架构中获取用户) const userIds = groupChecked.filter(id => { // 检查是否为用户ID(通常用户ID以特定前缀开头或有特定格式) return id.includes('user_'); }).map(id => parseInt(id.replace('user_', ''))); if (userIds.length === 0) { toastService.error("请选择具体的评查人员"); return; } // 构建负责人ID数组:当前用户(主要负责人)+ 额外负责人 const principalUserIds: number[] = []; // 添加当前用户作为主要负责人 if (currentUserId) { principalUserIds.push(parseInt(currentUserId.replace('user_', ''))); } // 添加额外的负责人 leaderIds.forEach(id => { const numId = parseInt(id.replace('user_', '')); if (!principalUserIds.includes(numId)) { principalUserIds.push(numId); } }); const uploadSuccesses: Array<{ file: CrossCheckingUploadedFile; documentId: number }> = []; const uploadFailures: Array<{ file: CrossCheckingUploadedFile; error: string }> = []; for (const fileInfo of filesToUpload) { const binaryData = await fileInfo.file.arrayBuffer(); const uploadResponse = await uploadCrossCheckingDocument( binaryData, fileInfo.name, fileInfo.type, selectedDocTypeId, priority, documentNumber, remark, isTestDocument, null, false, frontendJWT ); if (uploadResponse.error || !uploadResponse.data?.result?.id) { uploadFailures.push({ file: fileInfo, error: uploadResponse.error || '上传后未返回文档ID' }); continue; } uploadSuccesses.push({ file: fileInfo, documentId: uploadResponse.data.result.id }); } if (uploadFailures.length > 0) { toastService.error(`文件上传失败:${uploadFailures[0].error}`); return; } const createTaskResult = await createCrossReviewTask({ documentIds: uploadSuccesses.map(item => item.documentId), userIds, principalUserIds, taskName: taskInfo.name, docTypeId: selectedDocTypeId, docType: selectedDocType.code, taskType: taskInfo.type }, frontendJWT); if (!createTaskResult.success) { toastService.error(createTaskResult.error || '创建交叉评查任务失败'); return; } // 任务创建成功 toastService.success("交叉评查任务创建成功!"); messageService.success( `任务创建完成!\n任务名称:${taskInfo.name}\n上传文件:${uploadSuccesses.length} 个\n评查人员:${userIds.length} 人`, { title: '任务创建成功', confirmText: '确定', onConfirm: () => { // 跳转到任务列表页面 navigate('/cross-checking'); } } ); } catch (error) { console.error("创建任务失败:", error); toastService.error(`创建任务失败:${error instanceof Error ? error.message : '未知错误'}`); messageService.error(`创建任务失败:${error instanceof Error ? error.message : '未知错误'}`, { title: '创建失败', confirmText: '确定', }); } finally { setIsCreatingTask(false); setIsUploading(false); } }; // 处理完成上传(保留原有功能用于测试) // 处理完成上传(保留原有功能用于测试) // const handleCompleteUpload = async () => { // const filesToUpload = uploadType === 'single' ? singleFiles : multipleFiles; // if (filesToUpload.length === 0) { // toastService.error("请先选择要上传的文件"); // return; // } // setIsUploading(true); // try { // console.log("开始批量上传文件:", filesToUpload.length, "个,案卷类型:", caseType); // const result = await batchUploadCrossCheckingFiles( // filesToUpload.map(f => f.file), // caseType, // priority, // isTestDocument, // frontendJWT // ); // const { successes, failures } = result; // if (failures.length === 0) { // // 全部成功 // toastService.success(`成功上传 ${successes.length} 个文件`); // // 立即清空文件列表 // clearAllFiles(); // messageService.success(`文件上传完成!成功上传 ${successes.length} 个文件,现在可以进行下一步操作。`, { // title: '上传成功', // confirmText: '确定' // }); // } else if (successes.length === 0) { // // 全部失败 // toastService.error(`文件上传失败,共 ${failures.length} 个文件上传失败`); // messageService.error(`所有文件上传失败。失败原因:${failures[0].error}`, { // title: '上传失败', // confirmText: '确定', // }); // } else { // // 部分成功 // toastService.warning(`部分文件上传成功:成功 ${successes.length} 个,失败 ${failures.length} 个`); // messageService.warning( // `部分文件上传完成:\n成功:${successes.length} 个文件\n失败:${failures.length} 个文件\n\n失败文件:\n${failures.map(f => `${f.file.name}: ${f.error}`).join('\n')}`, // { // title: '部分上传成功', // confirmText: '确定', // } // ); // } // } catch (error) { // console.error("批量上传失败:", error); // toastService.error("文件上传过程中发生错误"); // messageService.error(`文件上传失败:${error instanceof Error ? error.message : '未知错误'}`, { // title: '上传失败', // confirmText: '确定', // }); // } finally { // setIsUploading(false); // } // }; // 步骤切换 const handleNext = () => setCurrentStep((s) => Math.min(s + 1, 3)); const handlePrev = () => setCurrentStep((s) => Math.max(s - 1, 1)); // 步骤1表单校验 const canNextStep1 = taskInfo.name.trim() && taskInfo.date.trim() && taskInfo.type.trim(); // 小组多选逻辑 - 默认不选择任何项 // 检查是否可以完成 const canComplete = uploadedFile !== null && !isUploading; // const navigation = useNavigation(); // 由于 isSubmitting 未被使用,暂时移除该行代码 const navigate = useNavigate(); // 加载组织架构数据(首次只加载组织结构,不含用户) useEffect(() => { const loadOrganizationData = async () => { // 只在步骤2且数据为空且未在加载时执行 if (currentStep === 2 && userSelectionState.treeData.length === 0 && !userSelectionState.loading) { setUserSelectionState(prev => ({ ...prev, loading: true, error: null })); try { // console.log('[loadOrganizationData] 开始加载组织架构数据'); // 首次只加载组织结构,不含用户(方案2) const response = await getOrganizationTree(false, frontendJWT); if (response.success && response.data) { // console.log('[loadOrganizationData] 原始API数据:', response.data); const treeData = convertToTreeData(response.data.organizations); // console.log('[loadOrganizationData] 转换后的树形数据:', treeData); setUserSelectionState({ treeData, loading: false, error: null }); } else { console.error('[loadOrganizationData] 获取组织架构失败:', response.error); setUserSelectionState({ treeData: DEFAULT_TREE, loading: false, error: response.error || '获取组织架构失败' }); toastService.error('获取组织架构失败,请刷新页面重试'); } } catch (error) { console.error('[loadOrganizationData] 加载组织架构数据失败:', error); setUserSelectionState({ treeData: DEFAULT_TREE, loading: false, error: error instanceof Error ? error.message : '加载组织架构数据失败' }); toastService.error('加载组织架构数据失败,请刷新页面重试'); } } }; loadOrganizationData(); }, [currentStep, frontendJWT]); // 懒加载子节点(点击部门时加载该部门的用户) const handleLoadChildren = async (node: TreeNodeItem): Promise => { // console.log('[handleLoadChildren] 懒加载子节点:', node.label, 'ou_id:', node.value); try { // 调用接口获取该部门的子树(含用户) const response = await getOrganizationTree(true, frontendJWT, node.value); if (response.success && response.data) { const organizations = response.data.organizations; if (organizations.length > 0) { // 从返回的树中找到目标节点,提取其用户数据 const users = extractUsersFromNode(organizations, node.value); // console.log('[handleLoadChildren] 提取到的用户数量:', users.length); return users; } } return []; } catch (error) { console.error('[handleLoadChildren] 加载子节点失败:', error); throw error; } }; // 搜索用户 const handleSearchUsers = async (keyword: string): Promise => { // console.log('[handleSearchUsers] 搜索用户:', keyword); try { const response = await searchUsers(keyword, 1, 50, frontendJWT); if (response.success && response.data?.users) { return convertSearchResultsToTreeNodes(response.data.users); } return []; } catch (error) { console.error('[handleSearchUsers] 搜索用户失败:', error); throw error; } }; // 从localStorage读取当前登录用户的完整信息 useEffect(() => { const loadCurrentUserInfoFromStorage = () => { if (!currentUserId || !userInfo) { return; } // 如果已经获取过,跳过 if (searchedUsers.has(currentUserId)) { return; } try { // 从localStorage读取user_info const storedUserInfo = typeof window !== 'undefined' ? localStorage.getItem('user_info') : null; if (storedUserInfo) { const parsedUserInfo = JSON.parse(storedUserInfo); // console.log('[loadCurrentUserInfoFromStorage] 从localStorage读取用户信息:', parsedUserInfo); // 构建符合UserInfo接口的数据 const fullUserInfo: UserInfo = { id: parsedUserInfo.user_id || userInfo.user_id, username: parsedUserInfo.username || userInfo.username, nick_name: parsedUserInfo.nick_name || userInfo.nick_name, area: parsedUserInfo.area || userInfo.area, ou_id: parsedUserInfo.ou_id || userInfo.ou_id, ou_name: parsedUserInfo.ou_name || userInfo.ou_name, is_leader: parsedUserInfo.is_leader ?? false, status: 0, // 以下字段可能存在于localStorage中 tenant_name: parsedUserInfo.tenant_name || null, dep_name: parsedUserInfo.dep_name || null, dep_short_name: parsedUserInfo.dep_short_name || null, email: parsedUserInfo.email, phone_number: parsedUserInfo.phone_number }; // 存储到searchedUsers中,供渲染时使用(使用currentUserId作为key) setSearchedUsers(prev => { const newMap = new Map(prev); newMap.set(currentUserId, fullUserInfo); return newMap; }); } } catch (error) { console.error('[loadCurrentUserInfoFromStorage] 读取localStorage用户信息失败:', error); } }; loadCurrentUserInfoFromStorage(); }, [currentUserId, userInfo]); return (
{/* 步骤指示器 */}
{STEPS.map((step) => (
{step.id}
{step.label}
))}
{/* 步骤1:创建任务 */} {currentStep === 1 && (
setTaskInfo({ ...taskInfo, name: e.target.value })} placeholder="请输入任务名称" />
setTaskInfo({ ...taskInfo, date: value })} className="w-full" id="task-date" placeholder="请选择日期" />
)} {/* 步骤2:创建评查小组 */} {currentStep === 2 && ( <>
{/* 左侧树状多选 */}
{userSelectionState.loading ? (
正在加载组织架构...
) : userSelectionState.error ? (
加载失败: {userSelectionState.error}
) : ( { // 同步懒加载后的树数据到父组件 setUserSelectionState(prev => ({ ...prev, treeData: newTreeData })); }} onAddUser={(userNode) => { // 从树中添加用户 if (groupChecked.includes(userNode.value)) { toastService.warning('该成员已添加'); return; } const newGroupChecked = [...groupChecked, userNode.value]; setGroupChecked(newGroupChecked); // 存储用户信息 if (userNode.userInfo) { setSearchedUsers(prev => { const newMap = new Map(prev); newMap.set(userNode.value, userNode.userInfo); return newMap; }); } }} onSearchUserAdded={(userNode) => { // 从搜索结果添加用户 if (groupChecked.includes(userNode.value)) { toastService.warning('该成员已添加'); return; } const newGroupChecked = [...groupChecked, userNode.value]; setGroupChecked(newGroupChecked); // 存储用户信息 if (userNode.userInfo) { setSearchedUsers(prev => { const newMap = new Map(prev); newMap.set(userNode.value, userNode.userInfo); return newMap; }); } }} /> )}
{/* 右侧已选择成员显示区域 */}

已选择的评查小组成员

{groupChecked.length > 0 ? (
{/* 将当前用户排在第一位 */} {[...groupChecked].sort((a, b) => { if (a === currentUserId) return -1; if (b === currentUserId) return 1; return 0; }).map((member, index) => { const isUser = member.startsWith('user_'); const isCurrentUser = member === currentUserId; const isLeader = leaderIds.includes(member); let displayName: string; let tenantName: string; let depName: string; let ouName: string; let fullOrgInfo: string; // 完整组织信息(用于tooltip) if (isUser) { // 获取用户完整信息(优先从搜索用户Map中查找) const userInfo = getUserInfoById(member, userSelectionState.treeData, searchedUsers); displayName = userInfo?.nick_name || member.replace('user_', ''); // 优先使用 organization_path 中的值(懒加载接口),为空则使用顶层字段(搜索接口) const orgPath = userInfo?.organization_path; tenantName = orgPath?.tenant_name || userInfo?.tenant_name || ''; depName = orgPath?.dep_name || userInfo?.dep_name || ''; ouName = userInfo?.ou_name || ''; // 完整信息:分公司-部门 const depOuName = depName && ouName ? `${depName}-${ouName}` : (depName || ouName); fullOrgInfo = depOuName; } else { // 组织(不应该有这种情况,因为只有用户才会被选中) displayName = member; tenantName = ''; depName = ''; ouName = ''; fullOrgInfo = '组织'; } return (
{/* 第一行:成员名称 + 总公司 */}
{displayName} {isCurrentUser && ( 主要负责人 )} {!isCurrentUser && isLeader && ( 负责人 )} {tenantName && ( {tenantName} )}
{/* 第二行:分公司-部门(省略显示) */} {(depName || ouName) && (
{depName}-{ouName}
)}
{/* 当前用户不能删除,也不显示设为负责人按钮 */} {isCurrentUser ? ( 不可更改 ) : isUser ? ( <> {/* 设为负责人/取消负责人按钮 */} {/* */} {/* 删除按钮 */} ) : null}
); })}
) : (
暂未选择评查小组成员
)}
共选择 {groupChecked.length} 名成员 {/* 主要负责人: 1 人 */}
{/* {leaderIds.length > 0 && (
额外负责人: {leaderIds.length} 人
)} */}
{/* 按钮区域移到卡片内部 */}
)} {/* 步骤3:原有上传区域 */} {currentStep === 3 && (
{/* 案卷类型选择器 */}
选择案卷类型
{documentTypesError ? (
加载案卷类型失败: {documentTypesError}
) : documentTypes && documentTypes.length > 0 ? (
{documentTypes.map((docType: DocumentType) => ( ))}
) : (
暂无可用的案卷类型
)}
{/* 合同类型选择器 - 仅在选择合同类型文档时显示 */} {selectedDocTypeId === 1 && (
选择合同类型(可选)
{CONTRACT_TYPES.map((type) => ( ))}
)} {/* 文件上传区域 - 左右布局 */}
{/* 左侧:上传区域 */}
上传文件
支持文件类型:
PDF DOCX ZIP/7Z
} disabled={isUploading || uploadedFile !== null} />
{/* 右侧:文件信息展示 */}
文件信息
{uploadedFile ? (
{/* 文件图标和类型 */}
{(() => { const fileName = uploadedFile.name.toLowerCase(); const isPdf = fileName.endsWith('.pdf'); const isDocx = fileName.endsWith('.docx'); const isZip = fileName.endsWith('.zip'); const is7z = fileName.endsWith('.7z'); if (isPdf) return ; if (isDocx) return ; if (isZip || is7z) return ; return ; })()}
{/* 文件详情 */}
文件名
{uploadedFile.name}
文件大小
{formatFileSize(uploadedFile.size)}
导入类型
{uploadedFile.uploadType === 'single' ? ( 单案件导入 ) : ( 多案件导入 )}
{/* 删除按钮 */}
) : (
暂未选择文件 请在左侧上传区域选择文件
)}
{/* 完成按钮 */}
{/* 文件选择状态提示 */} {!canComplete && !isUploading && (
请选择要上传的文件
)} {/* 创建任务进度提示 */} {(isUploading || isCreatingTask) && (
{isCreatingTask ? "正在创建任务..." : "正在上传文件..."}

{isCreatingTask ? `正在创建交叉评查任务:${taskInfo.name}` : `正在上传文件 ${uploadedFile?.name},请稍候` }

)}
)}
); }