Files
leaudit-platform-frontend/app/routes/cross-checking.upload.tsx
T
LiangShiyong d4000cd292 fix: 1. 继续对齐交叉评查的接口,完善创建交叉评查的逻辑 和 相关组件的渲染布局。
2. 文档的基本信息修改改用接口。      3. 重新完善角色权限管理的页面逻辑。     4.将评查点列表中的返回逻辑改用浏览器的记忆返回。
2025-12-12 12:00:36 +08:00

1027 lines
54 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 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
} from "~/api/user";
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: "选择卷宗" }
];
// 1. TreeNode类型和MOCK_TREE
export interface TreeNode {
label: string;
value: string;
children?: TreeNode[];
}
// 默认的空组织架构数据(作为备用)
const DEFAULT_TREE: TreeNode[] = [];
// 用户选择状态管理
interface UserSelectionState {
treeData: TreeNode[];
loading: boolean;
error: string | null;
}
function isAllChildrenChecked(node: TreeNode, checked: string[]): boolean {
if (!node.children || node.children.length === 0) return checked.includes(node.value);
return node.children.every(child => isAllChildrenChecked(child, checked));
}
function isSomeChildrenChecked(node: TreeNode, checked: string[]): boolean {
if (!node.children || node.children.length === 0) return checked.includes(node.value);
return node.children.some(child => isSomeChildrenChecked(child, checked));
}
const TreeNodeCheckbox: React.FC<{
node: TreeNode;
checked: string[];
onCheck: (node: TreeNode, checked: boolean) => void;
level?: number;
}> = ({ node, checked, onCheck, level = 0 }) => {
const [expanded, setExpanded] = React.useState(true);
const allChecked = isAllChildrenChecked(node, checked);
const someChecked = isSomeChildrenChecked(node, checked);
const isLeaf = !node.children || node.children.length === 0;
return (
<div style={{ marginLeft: level * 18 }}>
<div className="flex items-center">
{!isLeaf && (
<span
className="mr-1 cursor-pointer select-none"
onClick={() => setExpanded(e => !e)}
role="button"
tabIndex={0}
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && setExpanded(e => !e)}
style={{ width: 16, display: "inline-block", textAlign: "center" }}
>
<i className={expanded ? "ri-arrow-down-s-line" : "ri-arrow-right-s-line"}></i>
</span>
)}
<input
type="checkbox"
className="form-checkbox"
checked={allChecked}
ref={el => { if (el) el.indeterminate = !allChecked && someChecked; }}
onChange={e => onCheck(node, e.target.checked)}
id={node.value}
/>
<label htmlFor={node.value} className="ml-2">{node.label}</label>
</div>
{expanded && node.children && (
<div>
{node.children.map(child => (
<TreeNodeCheckbox
key={child.value}
node={child}
checked={checked}
onCheck={onCheck}
level={level + 1}
/>
))}
</div>
)}
</div>
);
};
/**
* 获取用户会话和前端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
});
// 上传配置状态 - 设置默认值
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('开始加载组织架构数据');
// 传递JWT token到API调用
const response = await getOrganizationTree(true, frontendJWT);
if (response.success && response.data) {
console.log('原始API数据:', response.data);
const treeData = convertToTreeData(response.data.organizations);
console.log('转换后的树形数据:', treeData);
// 验证数据转换是否正确
treeData.forEach(org => {
console.log(`组织: ${org.label} (${org.value})`);
if (org.children) {
org.children.forEach(child => {
if (child.isUser) {
console.log(` - 用户: ${child.label} (${child.value})`);
} else {
console.log(` - 子组织: ${child.label} (${child.value})`);
}
});
}
});
setUserSelectionState({
treeData,
loading: false,
error: null
});
} else {
console.error('获取组织架构失败:', response.error);
setUserSelectionState({
treeData: DEFAULT_TREE,
loading: false,
error: response.error || '获取组织架构失败'
});
toastService.error('获取组织架构失败,请刷新页面重试');
}
} catch (error) {
console.error('加载组织架构数据失败:', error);
setUserSelectionState({
treeData: DEFAULT_TREE,
loading: false,
error: error instanceof Error ? error.message : '加载组织架构数据失败'
});
toastService.error('加载组织架构数据失败,请刷新页面重试');
}
}
};
loadOrganizationData();
}, [currentStep]); // 只依赖 currentStep,避免无限循环
// 在 CrossCheckingUpload 组件内添加工具函数
function findUserNameById(tree: TreeNode[], userId: string): string | null {
for (const node of tree) {
if (node.value === userId && (node as { isUser?: boolean }).isUser) {
return node.label;
}
if (node.children) {
const found = findUserNameById(node.children, userId);
if (found) return found;
}
}
return null;
}
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: '1000px'}}>
<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}
placeholder="请选择评查小组成员"
value={groupChecked}
onChange={(values: string[]) => {
// 确保当前用户始终被选中
let newValues = values;
if (currentUserId && !values.includes(currentUserId)) {
newValues = [currentUserId, ...values];
}
setGroupChecked(newValues);
// 移除已被取消选中的负责人
setLeaderIds(prev => prev.filter(id => newValues.includes(id)));
}}
maxHeight={460}
searchable={true}
searchPlaceholder="搜索成员..."
/>
)}
</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) => {
let displayName: string = member;
let displayOrg = '';
const isUser = member.startsWith('user_');
const isCurrentUser = member === currentUserId;
const isLeader = leaderIds.includes(member);
if (isUser) {
// 查找真实用户名
const userName = findUserNameById(userSelectionState.treeData, member) || member.replace('user_', '');
displayName = userName;
displayOrg = '用户';
} else {
// 组织
const parts = member.split('-');
displayName = parts[parts.length - 1];
displayOrg = parts.slice(0, -1).join(' - ') || '组织';
}
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">
<div className="font-medium text-gray-800 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>
)}
</div>
<div className="text-gray-500 mt-1">{displayOrg}</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>
) : 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>
);
}