1157 lines
64 KiB
TypeScript
1157 lines
64 KiB
TypeScript
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,
|
||
batchUploadAndAssignCrossCheckingFiles,
|
||
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';
|
||
|
||
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 [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;
|
||
}
|
||
|
||
// 第一步:上传文件并自动分配任务(新接口)
|
||
// console.log("开始批量上传文件并分配任务:", filesToUpload.length, "个,案卷类型:", selectedDocType.name);
|
||
|
||
// 提取用户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;
|
||
}
|
||
|
||
// const requireParam = {
|
||
// filesToUpload: filesToUpload,
|
||
// selectedDocTypeId: selectedDocTypeId,
|
||
// priority: priority,
|
||
// documentNumber: documentNumber,
|
||
// remark: remark,
|
||
// isTestDocument: isTestDocument,
|
||
// userIds: userIds,
|
||
// taskInfo_name: taskInfo.name,
|
||
// selectedDocType_name: selectedDocType.code,
|
||
// taskInfo_type: taskInfo.type,
|
||
// frontendJWT
|
||
// }
|
||
|
||
// // console.log("requireParam", requireParam)
|
||
// 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);
|
||
}
|
||
});
|
||
|
||
// 使用文档类型名称作为 doc_type
|
||
const uploadResult = await batchUploadAndAssignCrossCheckingFiles(
|
||
filesToUpload,
|
||
selectedDocTypeId, // 使用选中的文档类型ID
|
||
priority,
|
||
documentNumber,
|
||
remark,
|
||
isTestDocument,
|
||
userIds,
|
||
taskInfo.name,
|
||
selectedDocType.code, // 使用文档类型code
|
||
taskInfo.type, // 使用任务类型(市局间交叉评查 或 区局间交叉评查)
|
||
frontendJWT,
|
||
principalUserIds // 负责人ID数组
|
||
);
|
||
|
||
|
||
// return;
|
||
|
||
const { successes, failures } = uploadResult;
|
||
|
||
if (failures.length > 0) {
|
||
toastService.error(`文件上传或任务分配失败:${failures[0].error}`);
|
||
return;
|
||
}
|
||
|
||
// 任务创建成功
|
||
toastService.success("交叉评查任务创建成功!");
|
||
messageService.success(
|
||
`任务创建完成!\n任务名称:${taskInfo.name}\n上传文件:${successes.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>
|
||
|
||
{/* 文件上传区域 - 左右布局 */}
|
||
<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>
|
||
);
|
||
} |