feat: wire real upload progress and subtype mapping

This commit is contained in:
wren
2026-05-06 18:33:53 +08:00
parent 57c744eb17
commit 61bbf6907b
2 changed files with 265 additions and 186 deletions
+78 -13
View File
@@ -147,8 +147,10 @@ export interface DocumentType {
name: string; name: string;
code?: string; code?: string;
entryModuleId?: number; entryModuleId?: number;
entryModuleName?: string | null;
isEnabled?: boolean; isEnabled?: boolean;
ruleSetIds?: number[]; ruleSetIds?: number[];
childDocumentTypeIds?: number[];
} }
export interface DocumentSubtypeGroup { export interface DocumentSubtypeGroup {
@@ -233,6 +235,12 @@ export interface UploadResult {
error?: string; error?: string;
} }
export interface UploadProgressInfo {
loaded: number;
total: number;
percent: number;
}
// 旧接口上传响应(uploadContractTemplate / appendContractAttachments 仍在使用) // 旧接口上传响应(uploadContractTemplate / appendContractAttachments 仍在使用)
interface LegacyUploadResponse { interface LegacyUploadResponse {
success: boolean; success: boolean;
@@ -684,6 +692,7 @@ export async function uploadDocumentToServer(
autoRun: boolean = true, autoRun: boolean = true,
speed: string = "normal", speed: string = "normal",
jwtToken?: string, jwtToken?: string,
onProgress?: (progress: UploadProgressInfo) => void,
): Promise<{ data: UploadResult } | { error: string; status?: number; payload?: UploadErrorPayload }> { ): Promise<{ data: UploadResult } | { error: string; status?: number; payload?: UploadErrorPayload }> {
try { try {
const formData = new FormData(); const formData = new FormData();
@@ -711,7 +720,23 @@ export async function uploadDocumentToServer(
headers["Authorization"] = `Bearer ${jwtToken}`; headers["Authorization"] = `Bearer ${jwtToken}`;
} }
const response = await axios.post(`${API_BASE_URL}/api/upload`, formData, { headers }); const response = await axios.post(`${API_BASE_URL}/api/upload`, formData, {
headers,
onUploadProgress: (event) => {
const fileBlob = formData.get("file");
const fallbackTotal = fileBlob instanceof Blob ? fileBlob.size : binaryData.byteLength;
const total = Number(event.total || fallbackTotal);
const loaded = Number(event.loaded || 0);
if (!total || !onProgress) {
return;
}
onProgress({
loaded,
total,
percent: Math.min(100, Math.max(0, Number(((loaded / total) * 100).toFixed(2)))),
});
},
});
const body = response.data; const body = response.data;
// Result<DocumentUploadVO> envelope // Result<DocumentUploadVO> envelope
@@ -826,8 +851,6 @@ export async function getDocumentTypes(token?: string): Promise<{data: DocumentT
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (selectedModuleId) { if (selectedModuleId) {
params.entry_module_id = String(selectedModuleId); params.entry_module_id = String(selectedModuleId);
} else if (documentTypeIds && documentTypeIds.length > 0) {
params.ids = documentTypeIds.join(",");
} }
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
@@ -835,18 +858,52 @@ export async function getDocumentTypes(token?: string): Promise<{data: DocumentT
headers["Authorization"] = `Bearer ${token}`; headers["Authorization"] = `Bearer ${token}`;
} }
const response = await axios.get(`${API_BASE_URL}/api/document-types`, { params, headers }); const [response, groupRoots] = await Promise.all([
axios.get(`${API_BASE_URL}/api/v3/document-type-roots`, { params, headers }),
fetchAllEvaluationPointGroupRoots(token),
]);
const body = response.data; const body = response.data;
if (body?.data && Array.isArray(body.data)) { if (body?.data && Array.isArray(body.data)) {
const types: DocumentType[] = body.data.map((item: { id: number; name: string; code?: string; entryModuleId?: number; isEnabled?: boolean; ruleSetIds?: number[] }) => ({ let types: DocumentType[] = body.data.map((item: {
id: number;
name: string;
code?: string;
entryModuleId?: number | null;
entryModuleName?: string | null;
isEnabled?: boolean;
ruleSetIds?: number[];
}) => {
const matchedRoot = groupRoots.find((root: any) => Number(root?.id || 0) === Number(item.id));
const childDocumentTypeIds = Array.isArray(matchedRoot?.children)
? Array.from(
new Set(
matchedRoot.children
.map((child: any) => Number(child?.document_type_id || 0))
.filter((childId: number) => childId > 0),
),
)
: [];
return {
id: item.id, id: item.id,
name: item.name, name: item.name,
code: item.code, code: item.code,
entryModuleId: item.entryModuleId, entryModuleId: item.entryModuleId ?? null,
entryModuleName: item.entryModuleName ?? null,
isEnabled: item.isEnabled, isEnabled: item.isEnabled,
ruleSetIds: item.ruleSetIds, ruleSetIds: item.ruleSetIds,
})); childDocumentTypeIds,
};
});
if (!selectedModuleId && documentTypeIds && documentTypeIds.length > 0) {
types = types.filter((item) =>
documentTypeIds.includes(item.id) ||
(item.childDocumentTypeIds || []).some((childId) => documentTypeIds.includes(childId)),
);
}
return { data: types }; return { data: types };
} }
return { error: body?.message || "获取文档类型失败", status: response.status }; return { error: body?.message || "获取文档类型失败", status: response.status };
@@ -919,18 +976,26 @@ async function fetchAllEvaluationPointGroupRoots(token?: string): Promise<any[]>
function collectSubtypeGroupsFromRoots( function collectSubtypeGroupsFromRoots(
roots: any[], roots: any[],
documentTypeId: number, rootOrDocumentTypeId: number,
entryModuleId?: number | null, entryModuleId?: number | null,
): DocumentSubtypeGroup[] { ): DocumentSubtypeGroup[] {
return dedupeSubtypeGroups( const scopedRoots = roots.filter((root: any) => {
roots.flatMap((root: any) => {
if (!Array.isArray(root?.children)) return [];
if (entryModuleId && Number(root?.entry_module_id || 0) !== Number(entryModuleId)) { if (entryModuleId && Number(root?.entry_module_id || 0) !== Number(entryModuleId)) {
return []; return false;
}
return true;
});
const matchedRoot = scopedRoots.find((root: any) => Number(root?.id || 0) === Number(rootOrDocumentTypeId));
if (matchedRoot && Array.isArray(matchedRoot.children)) {
return dedupeSubtypeGroups(matchedRoot.children.map((child: any) => mapSubtypeChild(child, matchedRoot)));
} }
return dedupeSubtypeGroups(
scopedRoots.flatMap((root: any) => {
if (!Array.isArray(root?.children)) return [];
return root.children return root.children
.filter((child: any) => Number(child?.document_type_id || 0) === Number(documentTypeId)) .filter((child: any) => Number(child?.document_type_id || 0) === Number(rootOrDocumentTypeId))
.map((child: any) => mapSubtypeChild(child, root)); .map((child: any) => mapSubtypeChild(child, root));
}), }),
); );
+179 -165
View File
@@ -25,6 +25,7 @@ import {
type DocumentType, type DocumentType,
type DocumentSubtypeGroup, type DocumentSubtypeGroup,
type UploadErrorDetails, type UploadErrorDetails,
type UploadProgressInfo,
type UploadResult, type UploadResult,
DocumentStatus DocumentStatus
} from "~/api/files/files-upload"; } from "~/api/files/files-upload";
@@ -139,6 +140,7 @@ async function handleFileUpload(
createdBy?: number, createdBy?: number,
attachments?: File[], attachments?: File[],
jwtToken?: string, jwtToken?: string,
onProgress?: (progress: UploadProgressInfo) => void,
): Promise<UploadResult> { ): Promise<UploadResult> {
const speed = priority === Priority.NORMAL ? "normal" : "urgent"; const speed = priority === Priority.NORMAL ? "normal" : "urgent";
@@ -154,6 +156,7 @@ async function handleFileUpload(
true, true,
speed, speed,
jwtToken, jwtToken,
onProgress,
); );
if ("error" in response || !response.data) { if ("error" in response || !response.data) {
@@ -172,6 +175,48 @@ async function handleFileUpload(
return response.data; return response.data;
} }
function mapProcessingStatusToProgress(status: DocumentStatus): number {
switch (status) {
case DocumentStatus.QUEUED:
case DocumentStatus.WAITING:
case DocumentStatus.waiting:
return 15;
case DocumentStatus.CUTTING:
return 35;
case DocumentStatus.EXTRACTIONING:
return 60;
case DocumentStatus.EVALUATIONING:
return 85;
case DocumentStatus.PROCESSED:
return 100;
case DocumentStatus.FAILED:
return 0;
default:
return 15;
}
}
function mapProcessingStatusToSpeed(status: DocumentStatus): string {
switch (status) {
case DocumentStatus.QUEUED:
case DocumentStatus.WAITING:
case DocumentStatus.waiting:
return "排队中";
case DocumentStatus.CUTTING:
return "切分中";
case DocumentStatus.EXTRACTIONING:
return "抽取中";
case DocumentStatus.EVALUATIONING:
return "评查中";
case DocumentStatus.PROCESSED:
return "已完成";
case DocumentStatus.FAILED:
return "处理失败";
default:
return "处理中";
}
}
// 定义action返回数据的类型 // 定义action返回数据的类型
type ActionData = { type ActionData = {
errors?: { errors?: {
@@ -199,7 +244,7 @@ export async function action({ request }: ActionFunctionArgs) {
const errors: Record<string, string> = {}; const errors: Record<string, string> = {};
if (!fileType) { if (!fileType) {
errors.fileType = "上传文件之前请选择文类型"; errors.fileType = "上传文件之前请选择文类型";
} }
if (!fileUpload) { if (!fileUpload) {
@@ -408,6 +453,9 @@ export default function FilesUpload() {
const hasMultipleSubtypeGroups = subtypeGroups.length > 1; const hasMultipleSubtypeGroups = subtypeGroups.length > 1;
const singleSubtypeGroup = subtypeGroups.length === 1 ? subtypeGroups[0] : null; const singleSubtypeGroup = subtypeGroups.length === 1 ? subtypeGroups[0] : null;
const isSingleDefaultSubtype = !!(singleSubtypeGroup && singleSubtypeGroup.isDefault); const isSingleDefaultSubtype = !!(singleSubtypeGroup && singleSubtypeGroup.isDefault);
const effectiveSubtypeGroup = selectedSubtypeGroup || singleSubtypeGroup;
const effectiveDocumentTypeId = effectiveSubtypeGroup?.documentTypeId || null;
const effectiveGroupId = effectiveSubtypeGroup?.id || null;
const selectedRootGroupName = selectedSubtypeGroup?.rootGroupName || singleSubtypeGroup?.rootGroupName || ""; const selectedRootGroupName = selectedSubtypeGroup?.rootGroupName || singleSubtypeGroup?.rootGroupName || "";
const selectedEntryModuleName = selectedSubtypeGroup?.entryModuleName || singleSubtypeGroup?.entryModuleName || ""; const selectedEntryModuleName = selectedSubtypeGroup?.entryModuleName || singleSubtypeGroup?.entryModuleName || "";
@@ -476,7 +524,11 @@ export default function FilesUpload() {
const scopedTypesResponse = await getDocumentTypes(loaderData.frontendJWT || undefined); const scopedTypesResponse = await getDocumentTypes(loaderData.frontendJWT || undefined);
if (!cancelled && !scopedTypesResponse.error && scopedTypesResponse.data) { if (!cancelled && !scopedTypesResponse.error && scopedTypesResponse.data) {
scopedTypes = scopedTypesResponse.data; scopedTypes = scopedTypesResponse.data;
effectiveTypeIds = scopedTypes.map(type => type.id); effectiveTypeIds = Array.from(
new Set(
scopedTypes.flatMap((type) => type.childDocumentTypeIds || []),
),
);
} }
} }
@@ -488,13 +540,11 @@ export default function FilesUpload() {
filterDocumentTypes(effectiveTypeIds, scopedTypes, normalizedModuleId); filterDocumentTypes(effectiveTypeIds, scopedTypes, normalizedModuleId);
await filterDocuments(effectiveTypeIds); await filterDocuments(effectiveTypeIds);
if (effectiveTypeIds && effectiveTypeIds.includes(1)) { if (scopedTypes.length === 1) {
setIsContractType(true); const onlyType = scopedTypes[0];
const contractType = scopedTypes.find(type => type.id === 1); setFileType(onlyType.id.toString());
if (contractType) { setIsContractType(onlyType.name.includes('合同'));
setFileType(contractType.id.toString());
setFileTypeError(null); setFileTypeError(null);
}
} else { } else {
setIsContractType(false); setIsContractType(false);
} }
@@ -527,7 +577,10 @@ export default function FilesUpload() {
} }
// 根据 documentTypeIds 过滤文档类型 // 根据 documentTypeIds 过滤文档类型
const filteredTypes = types.filter(type => documentTypeIds.includes(type.id)); const filteredTypes = types.filter(type =>
documentTypeIds.includes(type.id) ||
(type.childDocumentTypeIds || []).some((childId) => documentTypeIds.includes(childId))
);
setDocumentTypesState(filteredTypes); setDocumentTypesState(filteredTypes);
}; };
@@ -590,7 +643,6 @@ export default function FilesUpload() {
const [completedFiles, setCompletedFiles] = useState<UploadedFile[]>([]); const [completedFiles, setCompletedFiles] = useState<UploadedFile[]>([]);
// 计时器引用 - 分离为三个独立的定时器 // 计时器引用 - 分离为三个独立的定时器
const uploadProgressIntervalRef = useRef<NodeJS.Timeout | null>(null);
const processingStatusIntervalRef = useRef<NodeJS.Timeout | null>(null); const processingStatusIntervalRef = useRef<NodeJS.Timeout | null>(null);
const queueStatusIntervalRef = useRef<NodeJS.Timeout | null>(null); // 原 statusCheckIntervalRef const queueStatusIntervalRef = useRef<NodeJS.Timeout | null>(null); // 原 statusCheckIntervalRef
@@ -828,7 +880,9 @@ export default function FilesUpload() {
setFileTypeError(null); setFileTypeError(null);
// 检查是否选择了合同类型 // 检查是否选择了合同类型
const selectedType = loaderData.documentTypes.find(t => t.id.toString() === value); const selectedType =
documentTypesState.find(t => t.id.toString() === value) ||
loaderData.documentTypes.find(t => t.id.toString() === value);
const isContract = !!(selectedType && selectedType.name.includes('合同')); const isContract = !!(selectedType && selectedType.name.includes('合同'));
// console.log('【调试-handleFileTypeChange】文件类型检查:', { // console.log('【调试-handleFileTypeChange】文件类型检查:', {
// selectedType, // selectedType,
@@ -859,7 +913,7 @@ export default function FilesUpload() {
setFileType(""); setFileType("");
setIsContractType(false); setIsContractType(false);
// 如果用户选择了空选项,显示错误信息 // 如果用户选择了空选项,显示错误信息
setFileTypeError("上传文件之前请选择文类型"); setFileTypeError("上传文件之前请选择文类型");
} }
}; };
@@ -1264,7 +1318,12 @@ export default function FilesUpload() {
const mainFile = mainFiles[0]; const mainFile = mainFiles[0];
// 检查文档名称是否重复 // 检查文档名称是否重复
const duplicateResult = await checkDocumentDuplicate(mainFile.name, Number(fileType)); if (!effectiveDocumentTypeId) {
toastService.error('当前子类型缺少可提交的文档类型绑定,请先检查评查点分组配置');
return;
}
const duplicateResult = await checkDocumentDuplicate(mainFile.name, effectiveDocumentTypeId);
if (duplicateResult.is_duplicate) { if (duplicateResult.is_duplicate) {
const confirmed = window.confirm( const confirmed = window.confirm(
'存在同名文件,会归纳到历史版本中。如需纳入历史版本请点击确定,否则点击取消并重新命名文档后再上传。' '存在同名文件,会归纳到历史版本中。如需纳入历史版本请点击确定,否则点击取消并重新命名文档后再上传。'
@@ -1291,31 +1350,8 @@ export default function FilesUpload() {
setProcessingSteps(updatedSteps); setProcessingSteps(updatedSteps);
} }
// 计算总大小并开启与旧逻辑一致的模拟进度(按时间推进到 95%)
const totalSize = filesForProgress.reduce((sum, f) => sum + (f?.size || 0), 0); const totalSize = filesForProgress.reduce((sum, f) => sum + (f?.size || 0), 0);
const startTime = Date.now(); setUploadSpeed("上传中");
let lastUpdateTime = startTime;
let lastRatio = 0;
const estimatedUploadTime = Math.max(
(totalSize / (1024 * 1024)) / 3 * 1000, // 3MB/s 估算
1000
);
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
}
uploadProgressIntervalRef.current = setInterval(() => {
const now = Date.now();
const deltaSec = (now - lastUpdateTime) / 1000;
const ratio = Math.min((now - startTime) / estimatedUploadTime, 0.95);
// 计算瞬时速度(基于比例变化)
const deltaRatio = Math.max(ratio - lastRatio, 0);
const bytesPerSec = deltaSec > 0 ? (totalSize * deltaRatio) / deltaSec : 0;
lastRatio = ratio;
lastUpdateTime = now;
setUploadSpeed(`${formatFileSize(bytesPerSec)}/s`);
setUploadProgress(parseFloat((ratio * 100).toFixed(2)));
}, 200);
// 转二进制 // 转二进制
const binaryData = await uploadFileToBinary(mainFile); const binaryData = await uploadFileToBinary(mainFile);
@@ -1324,9 +1360,20 @@ export default function FilesUpload() {
const region = (loaderData.userInfo?.area as string) || "default"; const region = (loaderData.userInfo?.area as string) || "default";
const createdBy = loaderData.userInfo?.user_id as number | undefined; const createdBy = loaderData.userInfo?.user_id as number | undefined;
const uploadResp = await handleFileUpload( const uploadResp = await handleFileUpload(
binaryData, mainFile.name, mainFile.type, binaryData,
fileType as FileType, selectedGroupId ? Number(selectedGroupId) : null, priority, mainFile.name,
region, createdBy, attachmentFiles, loaderData.frontendJWT || undefined, mainFile.type,
String(effectiveDocumentTypeId),
effectiveGroupId,
priority,
region,
createdBy,
attachmentFiles,
loaderData.frontendJWT || undefined,
(progress) => {
setUploadProgress(progress.percent);
setUploadSpeed(`${formatFileSize(progress.loaded)}/${formatFileSize(progress.total)}`);
},
); );
if (!uploadResp.success) { if (!uploadResp.success) {
@@ -1350,10 +1397,6 @@ export default function FilesUpload() {
} }
// 完成:清理进度定时器并置满 // 完成:清理进度定时器并置满
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
uploadProgressIntervalRef.current = null;
}
setUploadProgress(100); setUploadProgress(100);
setUploadSpeed('完成'); setUploadSpeed('完成');
@@ -1378,11 +1421,6 @@ export default function FilesUpload() {
await filterDocuments(documentTypeIds); await filterDocuments(documentTypeIds);
} catch (error) { } catch (error) {
console.error('合同首传上传失败:', error); console.error('合同首传上传失败:', error);
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
uploadProgressIntervalRef.current = null;
}
// 清空合同模板文件缓存 // 清空合同模板文件缓存
setContractTemplateFiles([]); setContractTemplateFiles([]);
console.log('【合同上传失败】已清空合同模板文件缓存'); console.log('【合同上传失败】已清空合同模板文件缓存');
@@ -1412,7 +1450,7 @@ export default function FilesUpload() {
// 检查是否选择了文件类型 // 检查是否选择了文件类型
if (!fileType) { if (!fileType) {
console.error('【调试-checkAndPrepareUpload】未选择文件类型'); console.error('【调试-checkAndPrepareUpload】未选择文件类型');
toastService.error('请先选择文类型'); toastService.error('请先选择文类型');
return; return;
} }
if (subtypeGroups.length > 1 && !selectedGroupId) { if (subtypeGroups.length > 1 && !selectedGroupId) {
@@ -1421,7 +1459,7 @@ export default function FilesUpload() {
} }
// 检查是否为合同类型 // 检查是否为合同类型
const selectedType = loaderData.documentTypes.find(t => t.id.toString() === fileType); const selectedType = getSelectedDocumentType();
const isContract = !!(selectedType && selectedType.name.includes('合同')); const isContract = !!(selectedType && selectedType.name.includes('合同'));
// console.log('【调试-checkAndPrepareUpload】文件类型检查', { // console.log('【调试-checkAndPrepareUpload】文件类型检查', {
@@ -1454,7 +1492,12 @@ export default function FilesUpload() {
// 检查主文件名称是否重复(在任何状态变化之前进行检查) // 检查主文件名称是否重复(在任何状态变化之前进行检查)
const mainFile = allFiles[0]; const mainFile = allFiles[0];
const duplicateResult = await checkDocumentDuplicate(mainFile.name, Number(fileType)); if (!effectiveDocumentTypeId) {
toastService.error('当前子类型缺少可提交的文档类型绑定,请先检查评查点分组配置');
return;
}
const duplicateResult = await checkDocumentDuplicate(mainFile.name, effectiveDocumentTypeId);
if (duplicateResult.is_duplicate) { if (duplicateResult.is_duplicate) {
const confirmed = window.confirm( const confirmed = window.confirm(
'存在同名文件,会归纳到历史版本中。如需纳入历史版本请点击确定,否则点击取消并重新命名文档后再上传。' '存在同名文件,会归纳到历史版本中。如需纳入历史版本请点击确定,否则点击取消并重新命名文档后再上传。'
@@ -1505,8 +1548,8 @@ export default function FilesUpload() {
} }
} }
} else { } else {
console.error('【调试-checkAndPrepareUpload】未选择文类型,无法上传'); console.error('【调试-checkAndPrepareUpload】未选择文类型,无法上传');
toastService.error('请选择文类型'); toastService.error('请选择文类型');
} }
} else { } else {
console.error('【调试-checkAndPrepareUpload】没有文件可上传'); console.error('【调试-checkAndPrepareUpload】没有文件可上传');
@@ -1551,13 +1594,12 @@ export default function FilesUpload() {
setUploadStage("uploading"); setUploadStage("uploading");
setUploadProgress(0); setUploadProgress(0);
setUploadSpeed("上传中");
// 计算总文件大小 // 计算总文件大小
const totalSize = files.reduce((sum, file) => sum + file.size, 0); const totalSize = files.reduce((sum, file) => sum + file.size, 0);
let uploadedSize = 0; let uploadedSize = 0;
// console.log('【调试-startUpload】总文件大小:', formatFileSize(totalSize));
// 更新步骤状态 // 更新步骤状态
const updatedSteps = [...processingSteps]; const updatedSteps = [...processingSteps];
updatedSteps[0].status = "active"; updatedSteps[0].status = "active";
@@ -1571,33 +1613,6 @@ export default function FilesUpload() {
return; return;
} }
// 转换文件为二进制格式
// console.log("【调试-startUpload】开始转换文件到二进制格式...");
// 模拟上传进度
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
}
const startTime = Date.now();
let lastUploadedSize = 0;
let lastUpdateTime = startTime;
uploadProgressIntervalRef.current = setInterval(() => {
const currentTime = Date.now();
const timeElapsed = (currentTime - lastUpdateTime) / 1000; // 使用最近一次更新的时间间隔
const currentSpeed = timeElapsed > 0 ? (uploadedSize - lastUploadedSize) / timeElapsed : 0; // 字节/秒
lastUploadedSize = uploadedSize;
lastUpdateTime = currentTime;
// 更新上传速度显示
setUploadSpeed(`${formatFileSize(currentSpeed)}/s`);
// 更新进度 - 保留2位小数
const progress = Math.min((uploadedSize / totalSize) * 100, 99.99);
setUploadProgress(parseFloat(progress.toFixed(2)));
}, 200); // 改为200ms更新一次,提供更准确的速度计算
// 上传所有文件 // 上传所有文件
const uploadedFiles: UploadedFile[] = []; const uploadedFiles: UploadedFile[] = [];
@@ -1629,44 +1644,35 @@ export default function FilesUpload() {
// console.log(`【调试-startUpload】准备上传文件 ${file.name} 到服务器`); // console.log(`【调试-startUpload】准备上传文件 ${file.name} 到服务器`);
// 创建基于时间的渐进式进度模拟
const startUploadTime = Date.now();
// 根据文件大小动态估算上传时间,考虑网络速度
const estimatedUploadTime = Math.max(
file.size / (1024 * 1024) / 3 * 1000, // 假设3MB/s的速度,1MB需要1/3秒
1000 // 最小1秒
);
let progressInterval: NodeJS.Timeout | null = null;
// 开始渐进式进度更新
const progressPromise = new Promise<void>((resolve) => {
progressInterval = setInterval(() => {
const elapsed = Date.now() - startUploadTime;
const progressRatio = Math.min(elapsed / estimatedUploadTime, 0.95); // 最大95%
// 计算当前文件的进度贡献
const fileProgress = progressRatio * file.size;
const previousFilesSize = files.slice(0, temp_n - 1).reduce((sum, f) => sum + f.size, 0);
uploadedSize = previousFilesSize + fileProgress;
// 如果接近完成,停止进度更新并resolve
if (progressRatio >= 0.95) {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
resolve();
}
}, 100); // 改为100ms更新一次,提供更流畅的进度
});
// 使用Promise.race添加超时处理 // 使用Promise.race添加超时处理
const region = (loaderData.userInfo?.area as string) || "default"; const region = (loaderData.userInfo?.area as string) || "default";
const createdBy = loaderData.userInfo?.user_id as number | undefined; const createdBy = loaderData.userInfo?.user_id as number | undefined;
if (!effectiveDocumentTypeId) {
throw new Error("当前子类型缺少可提交的文档类型绑定,请先检查评查点分组配置");
}
const uploadPromise = handleFileUpload( const uploadPromise = handleFileUpload(
binaryData, file.name, file.type, binaryData,
fileType as FileType, selectedGroupId ? Number(selectedGroupId) : null, priority, file.name,
region, createdBy, undefined, loaderData.frontendJWT || undefined, file.type,
String(effectiveDocumentTypeId),
effectiveGroupId,
priority,
region,
createdBy,
undefined,
loaderData.frontendJWT || undefined,
(progress) => {
const previousFilesSize = files
.slice(0, temp_n - 1)
.reduce((sum, currentFile) => sum + currentFile.size, 0);
const currentLoaded = Math.min(progress.loaded, file.size);
uploadedSize = previousFilesSize + currentLoaded;
const overallProgress = totalSize > 0 ? (uploadedSize / totalSize) * 100 : 0;
setUploadProgress(parseFloat(Math.min(overallProgress, 100).toFixed(2)));
setUploadSpeed(
`${formatFileSize(previousFilesSize + currentLoaded)}/${formatFileSize(totalSize)}`,
);
},
); );
const timeoutPromise = new Promise<UploadResult>((_, reject) => { const timeoutPromise = new Promise<UploadResult>((_, reject) => {
@@ -1675,16 +1681,7 @@ export default function FilesUpload() {
}, 600000); }, 600000);
}); });
// 并行执行上传和进度更新 const uploadResult = await Promise.race([uploadPromise, timeoutPromise]);
const [uploadResult] = await Promise.all([
Promise.race([uploadPromise, timeoutPromise]),
progressPromise
]);
// 清除进度定时器
if (progressInterval) {
clearInterval(progressInterval);
}
// 再次检查组件是否已卸载 // 再次检查组件是否已卸载
if (!isMountedRef.current) { if (!isMountedRef.current) {
@@ -1748,10 +1745,6 @@ export default function FilesUpload() {
} }
// 清除进度定时器 // 清除进度定时器
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
}
// 更新上传状态 // 更新上传状态
setUploadProgress(100); setUploadProgress(100);
setUploadSpeed("完成"); setUploadSpeed("完成");
@@ -1763,7 +1756,7 @@ export default function FilesUpload() {
return { return {
id, id,
name: file.name, name: file.name,
type_id: fileType ? parseInt(fileType) : 0, type_id: effectiveDocumentTypeId || (fileType ? parseInt(fileType) : 0),
file_size: file.size, file_size: file.size,
status: DocumentStatus.CUTTING, status: DocumentStatus.CUTTING,
created_at: new Date().toISOString() created_at: new Date().toISOString()
@@ -1789,11 +1782,6 @@ export default function FilesUpload() {
setProcessingSteps(errorSteps); setProcessingSteps(errorSteps);
// 清除进度定时器
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
}
showFriendlyUploadError(error, { titlePrefix: '文件上传失败' }); showFriendlyUploadError(error, { titlePrefix: '文件上传失败' });
resetUpload(); resetUpload();
@@ -1824,6 +1812,8 @@ export default function FilesUpload() {
updatedSteps[1].description = "文档正在排队等待处理..."; updatedSteps[1].description = "文档正在排队等待处理...";
setProcessingSteps(updatedSteps); setProcessingSteps(updatedSteps);
setUploadProgress(mapProcessingStatusToProgress(DocumentStatus.QUEUED));
setUploadSpeed(mapProcessingStatusToSpeed(DocumentStatus.QUEUED));
// 获取文件ID列表 // 获取文件ID列表
const fileIds = files.map(file => file.id).filter(id => id > 0); const fileIds = files.map(file => file.id).filter(id => id > 0);
@@ -1926,6 +1916,17 @@ export default function FilesUpload() {
return; return;
} }
const hasFailed = response.data.some(doc => doc.status === DocumentStatus.FAILED);
if (hasFailed) {
if (processingStatusIntervalRef.current) {
clearInterval(processingStatusIntervalRef.current);
processingStatusIntervalRef.current = null;
}
updateProcessingSteps(DocumentStatus.FAILED);
updateQueueFilesStatus(response.data);
return;
}
// 检查是否所有文件都已完成处理 // 检查是否所有文件都已完成处理
const allCompleted = response.data.every(doc => doc.status === DocumentStatus.PROCESSED); const allCompleted = response.data.every(doc => doc.status === DocumentStatus.PROCESSED);
// console.log('【调试-checkProcessingStatus】文件处理状态:', { allCompleted, statusList: response.data.map(doc => doc.status) }); // console.log('【调试-checkProcessingStatus】文件处理状态:', { allCompleted, statusList: response.data.map(doc => doc.status) });
@@ -1955,6 +1956,8 @@ export default function FilesUpload() {
completedSteps[5].description = "文档已准备就绪,可以查看"; completedSteps[5].description = "文档已准备就绪,可以查看";
setProcessingSteps(completedSteps); setProcessingSteps(completedSteps);
setUploadProgress(100);
setUploadSpeed(mapProcessingStatusToSpeed(DocumentStatus.PROCESSED));
setUploadStage("completed"); setUploadStage("completed");
} else { } else {
// 根据当前状态更新步骤 // 根据当前状态更新步骤
@@ -2036,9 +2039,18 @@ export default function FilesUpload() {
updatedSteps[5].status = "done"; updatedSteps[5].status = "done";
updatedSteps[5].description = "文档已准备就绪,可以查看"; updatedSteps[5].description = "文档已准备就绪,可以查看";
break; break;
case DocumentStatus.FAILED:
updatedSteps[1].status = "done";
updatedSteps[1].description = "已进入处理队列";
updatedSteps[2].status = "error";
updatedSteps[2].description = "文档处理失败,请检查上传文件或稍后重试";
break;
} }
setProcessingSteps(updatedSteps); setProcessingSteps(updatedSteps);
setUploadProgress(mapProcessingStatusToProgress(status));
setUploadSpeed(mapProcessingStatusToSpeed(status));
}; };
// 更新队列中文件的状态 // 更新队列中文件的状态
@@ -2063,12 +2075,6 @@ export default function FilesUpload() {
// 重置上传状态 - 不清除队列状态检查定时器 // 重置上传状态 - 不清除队列状态检查定时器
const resetUpload = () => { const resetUpload = () => {
// 清除上传和处理相关的定时器
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
uploadProgressIntervalRef.current = null;
}
if (processingStatusIntervalRef.current) { if (processingStatusIntervalRef.current) {
clearInterval(processingStatusIntervalRef.current); clearInterval(processingStatusIntervalRef.current);
processingStatusIntervalRef.current = null; processingStatusIntervalRef.current = null;
@@ -2136,7 +2142,11 @@ export default function FilesUpload() {
// 获取文档类型名称 // 获取文档类型名称
const getDocumentTypeName = (codeId: number) => { const getDocumentTypeName = (codeId: number) => {
const type = documentTypesState.find(t => t.id === codeId); const type = documentTypesState.find(t => t.id === codeId);
return type ? type.name : '未知类型'; if (type) {
return type.name;
}
const matchedRoot = documentTypesState.find((item) => (item.childDocumentTypeIds || []).includes(codeId));
return matchedRoot ? matchedRoot.name : '未知类型';
}; };
// 处理查看文件 // 处理查看文件
@@ -2390,10 +2400,10 @@ export default function FilesUpload() {
{/* 文件类型选择和上传表单 */} {/* 文件类型选择和上传表单 */}
<Form method="post" encType="multipart/form-data" ref={formRef}> <Form method="post" encType="multipart/form-data" ref={formRef}>
{/* 文件类型选择 */} {/* 文件类型选择 */}
<Card title={<h3></h3>} className="mb-4"> <Card title={<h3></h3>} className="mb-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="form-group"> <div className="form-group">
<label htmlFor="file-type-select" className="form-label"> <span className="text-red-500">*</span></label> <label htmlFor="file-type-select" className="form-label"> <span className="text-red-500">*</span></label>
<select <select
id="file-type-select" id="file-type-select"
name="fileType" name="fileType"
@@ -2402,7 +2412,7 @@ export default function FilesUpload() {
onChange={handleFileTypeChange} onChange={handleFileTypeChange}
disabled={uploadStage !== "idle"} disabled={uploadStage !== "idle"}
> >
<option value=""></option> <option value=""></option>
{documentTypesState.map(type => ( {documentTypesState.map(type => (
<option key={type.id} value={type.id}>{type.name}</option> <option key={type.id} value={type.id}>{type.name}</option>
))} ))}
@@ -2413,7 +2423,7 @@ export default function FilesUpload() {
<div className="text-red-500 text-sm mt-1">{fileTypeError}</div> <div className="text-red-500 text-sm mt-1">{fileTypeError}</div>
)} )}
<div className="form-tip"></div> <div className="form-tip"></div>
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="priority-select" className="form-label"></label> <label htmlFor="priority-select" className="form-label"></label>
@@ -2446,11 +2456,11 @@ export default function FilesUpload() {
required={subtypeGroups.length > 1} required={subtypeGroups.length > 1}
> >
{!fileType ? ( {!fileType ? (
<option value=""></option> <option value=""></option>
) : groupOptionsLoading ? ( ) : groupOptionsLoading ? (
<option value="">...</option> <option value="">...</option>
) : subtypeGroups.length === 0 ? ( ) : subtypeGroups.length === 0 ? (
<option value=""></option> <option value=""></option>
) : subtypeGroups.length === 1 ? ( ) : subtypeGroups.length === 1 ? (
<option value={String(subtypeGroups[0].id)}> <option value={String(subtypeGroups[0].id)}>
{getSubtypeDisplayName(subtypeGroups[0])} {getSubtypeDisplayName(subtypeGroups[0])}
@@ -2468,27 +2478,35 @@ export default function FilesUpload() {
</select> </select>
<div className="form-tip"> <div className="form-tip">
{!fileType {!fileType
? "请先选择文件类型,再确定本次上传实际命中的子类型。" ? "请先选择一级文档类型,再确定本次上传实际命中的子类型。"
: groupOptionsLoading : groupOptionsLoading
? "正在加载当前文档类型下可用的子类型配置。" ? "正在加载当前一级文档类型下可用的子类型配置。"
: subtypeGroups.length === 0 : subtypeGroups.length === 0
? "当前文档类型在当前入口下还没有可用子类型,请先到评查点分组管理补齐“一级分组 / 二级分组 / 规则集”绑定。" ? "当前一级文档类型在当前入口下还没有可用子类型,请先到评查点分组管理补齐“一级分组 / 二级分组 / 规则集”绑定。"
: hasMultipleSubtypeGroups : hasMultipleSubtypeGroups
? "同一文档类型在当前入口下已拆分多个子类型,请选择本次上传实际命中的子类型。" ? "当前一级文档类型在当前入口下已拆分多个子类型,请选择本次上传实际命中的子类型。"
: isSingleDefaultSubtype : isSingleDefaultSubtype
? "当前文档类型在当前入口下尚未拆分业务子类型,系统将按默认子类型处理;后续可在评查点分组管理中继续细分。" ? "当前一级文档类型在当前入口下尚未拆分业务子类型,系统将按默认子类型处理;后续可在评查点分组管理中继续细分。"
: "当前文档类型在当前入口下仅配置了个子类型,系统自动带出该子类型。"} : "当前一级文档类型在当前入口下仅配置了 1 个子类型,系统自动带出。"}
</div> </div>
{selectedRootGroupName ? ( {selectedRootGroupName ? (
<div className="form-tip"> <div className="form-tip">
{selectedRootGroupName} {selectedRootGroupName}
{selectedEntryModuleName ? ` · 入口模块:${selectedEntryModuleName}` : ""}
</div> </div>
) : null} ) : null}
{selectedSubtypeGroup ? ( {selectedEntryModuleName ? (
<div className="form-tip"> <div className="form-tip">
{getSubtypeDisplayName(selectedSubtypeGroup)} {selectedEntryModuleName}
{selectedSubtypeGroup.displayHint ? ` · ${selectedSubtypeGroup.displayHint}` : ""} </div>
) : null}
{effectiveSubtypeGroup ? (
<div className="form-tip">
{getSubtypeDisplayName(effectiveSubtypeGroup)}
</div>
) : null}
{effectiveSubtypeGroup?.code ? (
<div className="form-tip">
{effectiveSubtypeGroup.code}
</div> </div>
) : null} ) : null}
</div> </div>
@@ -2802,7 +2820,7 @@ export default function FilesUpload() {
fileName={`${currentFiles.length}个文件`} fileName={`${currentFiles.length}个文件`}
fileSize={formatFileSize(currentFiles.reduce((sum, file) => sum + file.size, 0))} fileSize={formatFileSize(currentFiles.reduce((sum, file) => sum + file.size, 0))}
progress={uploadProgress} progress={uploadProgress}
speed={''} speed={uploadSpeed}
/> />
)} )}
@@ -2867,10 +2885,6 @@ export default function FilesUpload() {
type="default" type="default"
icon="ri-refresh-line" icon="ri-refresh-line"
onClick={() => { onClick={() => {
// 清除所有定时器
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
}
if (processingStatusIntervalRef.current) { if (processingStatusIntervalRef.current) {
clearInterval(processingStatusIntervalRef.current); clearInterval(processingStatusIntervalRef.current);
} }