Files
2026-05-07 18:18:35 +08:00

1195 lines
66 KiB
TypeScript
Raw Permalink 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 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<string, UserInfo>
): 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<typeof loader>();
// 基础状态 - 使用第一个文档类型的ID作为默认值
const [selectedDocTypeId, setSelectedDocTypeId] = useState<number | null>(
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<string[]>(currentUserId ? [currentUserId] : []);
const [leaderIds, setLeaderIds] = useState<string[]>([]); // 额外的负责人ID数组(不包含当前用户)
const [userSelectionState, setUserSelectionState] = useState<UserSelectionState>({
treeData: DEFAULT_TREE,
loading: false,
error: null
});
// 存储从搜索结果添加的用户信息(这些用户不在 treeData 中)
const [searchedUsers, setSearchedUsers] = useState<Map<string, UserInfo>>(new Map());
// 上传配置状态 - 设置默认值
const [priority] = useState<string>("normal");
const [documentNumber] = useState<string>("");
const [remark] = useState<string>("");
const [isTestDocument] = useState<boolean>(false);
const [attributeType, setAttributeType] = useState<string>(DEFAULT_CONTRACT_TYPE);
// 文件管理状态 - 简化为单文件上传
const [uploadedFile, setUploadedFile] = useState<CrossCheckingUploadedFile | null>(null);
const [isUploading, setIsUploading] = useState(false);
// 引用
const uploadRef = useRef<UploadAreaRef>(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<TreeNodeItem[]> => {
// 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<TreeNodeItem[]> => {
// 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 (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-6xl mx-auto px-4">
{/* 步骤指示器 */}
<div className="steps-indicator">
{STEPS.map((step) => (
<div
key={step.id}
className={`step-item ${step.id < currentStep ? 'completed' : ''}`}
>
<div className={`step-circle ${step.id === currentStep ? 'active' : 'inactive'}`}>{step.id}</div>
<div className="step-label">{step.label}</div>
</div>
))}
</div>
{/* 步骤1:创建任务 */}
{currentStep === 1 && (
<div className="flex justify-center">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6" style={{width: '1000px'}}>
<div className="form-group">
<label htmlFor="task-name"><span className="text-red-500">*</span></label>
<input
id="task-name"
className="form-input"
value={taskInfo.name}
onChange={e => setTaskInfo({ ...taskInfo, name: e.target.value })}
placeholder="请输入任务名称"
/>
</div>
<div className="form-group">
<label htmlFor="task-date" className="form-label required"></label>
<SingleDatePicker
date={taskInfo.date}
onDateChange={(value) => setTaskInfo({ ...taskInfo, date: value })}
className="w-full"
id="task-date"
placeholder="请选择日期"
/>
</div>
<div className="form-group">
<label htmlFor="task-type"></label>
<select
id="task-type"
className="form-select"
value={taskInfo.type}
onChange={e => setTaskInfo({ ...taskInfo, type: e.target.value })}
>
<option value="CITY"></option>
<option value="DISTRICT"></option>
</select>
</div>
<div className="flex justify-between items-center mt-6">
<Button
type="default"
icon="ri-arrow-left-line"
onClick={() => {
// console.log('点击返回列表按钮');
navigate('/cross-checking');
}}
>
</Button>
<Button type="primary" disabled={!canNextStep1} onClick={handleNext}></Button>
</div>
</div>
</div>
)}
{/* 步骤2:创建评查小组 */}
{currentStep === 2 && (
<>
<div className="flex justify-center">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6" style={{width: '1200px'}}>
<div className="flex flex-row justify-center gap-12">
{/* 左侧树状多选 */}
<div style={{ minWidth: 300, width: '40%' }}>
<div className="form-group">
<label htmlFor="review-group" className="form-label required"></label>
{userSelectionState.loading ? (
<div className="flex items-center justify-center p-4 border border-gray-200 rounded-md bg-gray-50">
<i className="ri-loader-4-line ri-spin animate-spin text-xl mr-2 text-blue-600"></i>
<span className="text-gray-600">...</span>
</div>
) : userSelectionState.error ? (
<div className="flex items-center justify-center p-4 border border-red-200 rounded-md bg-red-50">
<i className="ri-error-warning-line text-xl mr-2 text-red-600"></i>
<span className="text-red-600">: {userSelectionState.error}</span>
</div>
) : (
<MultiCascader
options={userSelectionState.treeData}
selectedUsers={groupChecked}
placeholder="请选择评查小组成员"
maxHeight={460}
searchable={true}
searchPlaceholder="搜索成员..."
onLoadChildren={handleLoadChildren}
onSearchUsers={handleSearchUsers}
onTreeDataChange={(newTreeData) => {
// 同步懒加载后的树数据到父组件
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;
});
}
}}
/>
)}
</div>
</div>
{/* 右侧已选择成员显示区域 */}
<div style={{ minWidth: 400, width: '50%', minHeight: 250, background: '#f9fafb', border: '1.5px solid #e5e7eb', borderRadius: 8, marginTop: '23px' }}>
<div className="p-4">
<h4 className="text-sm font-medium text-gray-700 mb-3"></h4>
{groupChecked.length > 0 ? (
<div className="space-y-2 max-h-[460px] overflow-y-auto">
{/* 将当前用户排在第一位 */}
{[...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 (
<div
key={index}
className={`bg-white p-2 rounded text-xs border flex items-center justify-between ${
isCurrentUser
? 'border-amber-400 bg-amber-50'
: isLeader
? 'border-[var(--color-primary)] bg-[rgba(0,104,74,0.05)]'
: ''
}`}
>
<div className="flex-1 min-w-0">
{/* 第一行:成员名称 + 总公司 */}
<div className="font-medium text-gray-800 flex items-center gap-5">
<span className="flex items-center gap-2">
{displayName}
{isCurrentUser && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-500 text-white">
<i className="ri-user-star-fill mr-0.5"></i>
</span>
)}
{!isCurrentUser && isLeader && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[var(--color-primary)] text-white">
<i className="ri-star-fill mr-0.5"></i>
</span>
)}
</span>
{tenantName && (
<span className="text-gray-500 text-[10px]">{tenantName}</span>
)}
</div>
{/* 第二行:分公司-部门(省略显示) */}
{(depName || ouName) && (
<div
className="text-gray-500 text-[10px] truncate mt-1"
title={fullOrgInfo}
>
{depName}-{ouName}
</div>
)}
</div>
{/* 当前用户不能删除,也不显示设为负责人按钮 */}
{isCurrentUser ? (
<span className="ml-2 px-2 py-1 rounded text-[10px] bg-gray-100 text-gray-400 cursor-not-allowed">
</span>
) : isUser ? (
<>
{/* 设为负责人/取消负责人按钮 */}
{/* <button
type="button"
className={`ml-2 px-2 py-1 rounded text-[10px] transition-colors ${
isLeader
? 'bg-gray-100 text-gray-500 hover:bg-gray-200'
: 'bg-[var(--color-primary-light)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-white'
}`}
onClick={() => {
if (isLeader) {
setLeaderIds(prev => prev.filter(id => id !== member));
} else {
setLeaderIds(prev => [...prev, member]);
}
}}
title={isLeader ? '取消负责人' : '设为负责人'}
>
{isLeader ? '取消负责人' : '设为负责人'}
</button> */}
{/* 删除按钮 */}
<button
type="button"
className="ml-1 w-6 h-6 flex items-center justify-center rounded-full text-gray-400 hover:text-red-500 hover:bg-red-50 transition-colors"
onClick={() => {
// 从已选择列表中移除
const newGroupChecked = groupChecked.filter(id => id !== member);
setGroupChecked(newGroupChecked);
// 同时从负责人列表中移除
setLeaderIds(prev => prev.filter(id => id !== member));
}}
title="删除"
>
<i className="ri-close-line text-sm"></i>
</button>
</>
) : null}
</div>
);
})}
</div>
) : (
<div className="text-gray-400 text-sm text-center mt-8">
<i className="ri-user-line text-2xl mb-2 block"></i>
</div>
)}
<div className="mt-4 pt-3 border-t border-gray-200">
<div className="text-xs text-gray-500 flex flex-col gap-1">
<div className="flex items-center justify-between">
<span> {groupChecked.length} </span>
{/* <span className="text-amber-600">
<i className="ri-user-star-fill mr-1"></i>
主要负责人: 1 人
</span> */}
</div>
{/* {leaderIds.length > 0 && (
<div className="flex items-center justify-end">
<span className="text-[var(--color-primary)]">
<i className="ri-star-fill mr-1"></i>
额外负责人: {leaderIds.length} 人
</span>
</div>
)} */}
</div>
</div>
</div>
</div>
</div>
{/* 按钮区域移到卡片内部 */}
<div className="flex justify-between items-center mt-6">
<Button
type="default"
icon="ri-arrow-left-line"
onClick={() => {
// console.log('点击返回列表按钮');
navigate('/cross-checking');
}}
>
</Button>
<div className="flex space-x-4">
<Button type="default" onClick={handlePrev}></Button>
<Button type="primary" disabled={groupChecked.length === 0} onClick={handleNext}></Button>
</div>
</div>
</div>
</div>
</>
)}
{/* 步骤3:原有上传区域 */}
{currentStep === 3 && (
<div className="flex justify-center">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6" style={{width: '1000px'}}>
{/* 案卷类型选择器 */}
<div className="flex justify-center mb-6">
<div>
<div className="text-sm font-medium text-gray-700 mb-3 text-center"></div>
{documentTypesError ? (
<div className="text-red-500 text-sm text-center p-4 border border-red-200 rounded-md bg-red-50">
<i className="ri-error-warning-line mr-2"></i>
: {documentTypesError}
</div>
) : documentTypes && documentTypes.length > 0 ? (
<div className="case-type-options">
{documentTypes.map((docType: DocumentType) => (
<button
key={docType.id}
type="button"
className={`case-type-option ${selectedDocTypeId === docType.id ? 'active' : 'inactive'}`}
onClick={() => handleDocTypeChange(docType.id)}
disabled={isUploading}
>
{docType.name}
</button>
))}
</div>
) : (
<div className="text-gray-500 text-sm text-center p-4 border border-gray-200 rounded-md bg-gray-50">
<i className="ri-information-line mr-2"></i>
</div>
)}
</div>
</div>
{/* 合同类型选择器 - 仅在选择合同类型文档时显示 */}
{selectedDocTypeId === 1 && (
<div className="flex justify-center mb-6">
<div className="w-full max-w-2xl">
<div className="text-sm font-medium text-gray-700 mb-3 text-center"></div>
<div className="grid grid-cols-4 gap-2">
{CONTRACT_TYPES.map((type) => (
<button
key={type.value}
type="button"
className={`px-3 py-2 text-sm rounded-md border transition-colors ${
attributeType === type.value
? 'bg-[var(--color-primary)] text-white border-[var(--color-primary)]'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
onClick={() => setAttributeType(type.value)}
disabled={isUploading}
>
{type.label}
</button>
))}
</div>
</div>
</div>
)}
{/* 文件上传区域 - 左右布局 */}
<input type="hidden" name="selectedDocTypeId" value={selectedDocTypeId || ''} />
<div className="flex gap-6">
{/* 左侧:上传区域 */}
<div className="flex-1">
<div className="text-sm font-medium text-gray-700 mb-3"></div>
<UploadArea
ref={uploadRef}
onFilesSelected={handleFileSelected}
className="custom-upload-area"
accept=".pdf,.docx,.zip,.7z"
multiple={false}
icon="ri-upload-cloud-2-line"
buttonText="选择文件"
mainText="点击或拖拽文件到此区域上传"
tipText={
<div className="text-gray-500 text-xs mt-2">
<div></div>
<div className="flex flex-wrap gap-2 mt-1 justify-center">
<span className="inline-flex items-center px-2 py-0.5 rounded bg-red-50 text-red-600">
<i className="ri-file-pdf-line mr-1"></i>PDF
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded bg-blue-50 text-blue-600">
<i className="ri-file-word-2-line mr-1"></i>DOCX
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded bg-orange-50 text-orange-600">
<i className="ri-folder-zip-line mr-1"></i>ZIP/7Z
</span>
</div>
</div>
}
disabled={isUploading || uploadedFile !== null}
/>
</div>
{/* 右侧:文件信息展示 */}
<div className="flex-1">
<div className="text-sm font-medium text-gray-700 mb-3"></div>
<div className="border border-gray-200 rounded-lg bg-gray-50 min-h-[200px] p-4">
{uploadedFile ? (
<div className="h-full flex flex-col">
{/* 文件图标和类型 */}
<div className="flex items-center justify-center mb-4">
{(() => {
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 <i className="ri-file-pdf-line text-5xl text-red-500"></i>;
if (isDocx) return <i className="ri-file-word-2-line text-5xl text-blue-500"></i>;
if (isZip || is7z) return <i className="ri-folder-zip-line text-5xl text-orange-500"></i>;
return <i className="ri-file-line text-5xl text-gray-500"></i>;
})()}
</div>
{/* 文件详情 */}
<div className="flex-1 space-y-3">
<div className="bg-white rounded-md p-3 border border-gray-200">
<div className="text-xs text-gray-500 mb-1"></div>
<div className="text-sm font-medium text-gray-800 truncate" title={uploadedFile.name}>
{uploadedFile.name}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="bg-white rounded-md p-3 border border-gray-200">
<div className="text-xs text-gray-500 mb-1"></div>
<div className="text-sm font-medium text-gray-800">
{formatFileSize(uploadedFile.size)}
</div>
</div>
<div className="bg-white rounded-md p-3 border border-gray-200">
<div className="text-xs text-gray-500 mb-1"></div>
<div className="text-sm font-medium text-gray-800">
{uploadedFile.uploadType === 'single' ? (
<span className="text-blue-600"></span>
) : (
<span className="text-orange-600"></span>
)}
</div>
</div>
</div>
</div>
{/* 删除按钮 */}
<div className="mt-4 pt-3 border-t border-gray-200">
<button
type="button"
onClick={handleRemoveFile}
disabled={isUploading}
className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm text-red-600 bg-red-50 hover:bg-red-100 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<i className="ri-delete-bin-line"></i>
</button>
</div>
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-gray-400">
<i className="ri-file-line text-4xl mb-2"></i>
<span className="text-sm"></span>
<span className="text-xs mt-1"></span>
</div>
)}
</div>
</div>
</div>
{/* 完成按钮 */}
<div className="flex justify-between items-center mt-8">
<Button
type="default"
icon="ri-arrow-left-line"
onClick={() => {
// console.log('点击返回列表按钮');
navigate('/cross-checking');
}}
>
</Button>
<div className="flex space-x-4">
<Button type="default" onClick={handlePrev}></Button>
<Button
type="primary"
disabled={!canComplete || isUploading || isCreatingTask}
onClick={handleCreateTask}
>
{isCreatingTask ? "创建任务中..." : isUploading ? "上传中..." : "开始创建任务"}
</Button>
</div>
</div>
{/* 文件选择状态提示 */}
{!canComplete && !isUploading && (
<div className="text-center mt-4 text-gray-500 text-sm">
</div>
)}
{/* 创建任务进度提示 */}
{(isUploading || isCreatingTask) && (
<div className="text-center mt-4">
<div className="bg-blue-50 p-4 rounded-md border border-blue-100">
<div className="flex items-center justify-center text-blue-800 mb-2">
<i className="ri-loader-4-line ri-spin animate-spin text-xl mr-2"></i>
<span className="font-medium">
{isCreatingTask ? "正在创建任务..." : "正在上传文件..."}
</span>
</div>
<p className="text-sm text-blue-700">
{isCreatingTask
? `正在创建交叉评查任务:${taskInfo.name}`
: `正在上传文件 ${uploadedFile?.name},请稍候`
}
</p>
</div>
</div>
)}
</div>
</div>
)}
</div>
</div>
);
}