feat: stabilize document type and upload flows

This commit is contained in:
wren
2026-04-30 17:44:05 +08:00
parent 81c5e98b53
commit 3fb7e9f5d0
18 changed files with 2122 additions and 491 deletions
+169 -38
View File
@@ -11,6 +11,7 @@ import uploadStyles from "~/styles/pages/files_upload.css?url";
import { messageService } from "~/components/ui/MessageModal";
import { toastService } from "~/components/ui/Toast";
import {
buildUploadErrorDetails,
getTodayDocuments,
getDocumentTypes,
getDocumentsStatus,
@@ -21,6 +22,7 @@ import {
checkDocumentDuplicate,
type Document,
type DocumentType,
type UploadErrorDetails,
type UploadResult,
DocumentStatus
} from "~/api/files/files-upload";
@@ -119,6 +121,11 @@ export interface UploadedFile {
};
}
type UploadRequestError = Error & {
status?: number;
payload?: unknown;
};
// 修改文件上传函数部分,解决类型问题
async function handleFileUpload(
binaryData: ArrayBuffer,
@@ -147,7 +154,14 @@ async function handleFileUpload(
if ("error" in response || !response.data) {
const errMsg = "error" in response ? response.error : "上传响应为空";
console.error("上传失败:", errMsg);
throw new Error(errMsg || "上传失败");
const uploadError = new Error(errMsg || "上传失败") as UploadRequestError;
if ("status" in response) {
uploadError.status = response.status;
}
if ("payload" in response) {
uploadError.payload = response.payload;
}
throw uploadError;
}
return response.data;
@@ -369,41 +383,123 @@ export default function FilesUpload() {
// 队列文件状态
const [queueFiles, setQueueFiles] = useState<Document[]>([]);
const [documentTypesState, setDocumentTypesState] = useState<DocumentType[]>([]);
const [uploadErrorDetails, setUploadErrorDetails] = useState<UploadErrorDetails | null>(null);
// 全局队列状态(用于显示排队统计)
const [globalQueueStatus, setGlobalQueueStatus] = useState<QueueStatus | null>(null);
const getSelectedDocumentType = () =>
documentTypesState.find(type => type.id.toString() === fileType) ||
loaderData.documentTypes.find(type => type.id.toString() === fileType) ||
null;
const clearUploadErrorDetails = () => {
setUploadErrorDetails(null);
};
const showFriendlyUploadError = (
error: unknown,
options?: {
titlePrefix?: string;
resetAfterClose?: boolean;
}
) => {
const uploadError = error instanceof Error ? (error as UploadRequestError) : null;
const rawMessage = uploadError?.message || (typeof error === 'string' ? error : '未知错误');
const details = buildUploadErrorDetails(rawMessage, {
documentType: getSelectedDocumentType(),
status: uploadError?.status,
payload: (uploadError?.payload as Record<string, unknown> | null | undefined),
});
setUploadErrorDetails(details);
const detailPreview = details.detailLines.slice(0, 2).join('\n');
const actionPreview = details.actionLines[0] ? `\n\n建议:${details.actionLines[0]}` : '';
const modalMessage = `${details.summary}${detailPreview ? `\n\n${detailPreview}` : ''}${actionPreview}`;
messageService.error(modalMessage, {
title: options?.titlePrefix ? `${options.titlePrefix} - ${details.title}` : details.title,
confirmText: '确定',
cancelText: '',
onConfirm: options?.resetAfterClose ? () => resetUpload() : undefined,
});
};
// 在组件挂载时从 sessionStorage 获取 documentTypeIds
useEffect(() => {
try {
// 在客户端环境中执行
if (typeof window !== 'undefined') {
const typeIdsStr = sessionStorage.getItem('documentTypeIds');
const typeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null;
setDocumentTypeIds(typeIds);
// 根据 documentTypeIds 过滤文档类型和文档列表
filterDocumentTypes(typeIds, loaderData.documentTypes);
filterDocuments(typeIds);
let cancelled = false;
// 如果包含合同类型(ID=1),自动选择合同文档类型
if (typeIds && typeIds.includes(1)) {
setIsContractType(true);
// 查找ID为1的合同文档类型
const contractType = loaderData.documentTypes.find(type => type.id === 1);
if (contractType) {
setFileType(contractType.id.toString());
// 清除可能存在的文件类型错误
setFileTypeError(null);
try {
if (typeof window === 'undefined') {
return;
}
const bootstrapUploadScope = async () => {
const typeIdsStr = sessionStorage.getItem('documentTypeIds');
const cachedTypeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null;
const selectedModuleIdStr = sessionStorage.getItem('selectedModuleId');
const nextSelectedModuleId = selectedModuleIdStr ? Number(selectedModuleIdStr) : null;
const normalizedModuleId = Number.isFinite(nextSelectedModuleId) && (nextSelectedModuleId || 0) > 0
? nextSelectedModuleId
: null;
if (cancelled) {
return;
}
let scopedTypes = loaderData.documentTypes;
let effectiveTypeIds = cachedTypeIds;
// 客户端可读取 sessionStorage,因此这里重新按入口模块拉一次,避免 SSR 阶段拿到全量类型后继续走旧缓存。
if (normalizedModuleId) {
const scopedTypesResponse = await getDocumentTypes(loaderData.frontendJWT || undefined);
if (!cancelled && !scopedTypesResponse.error && scopedTypesResponse.data) {
scopedTypes = scopedTypesResponse.data;
effectiveTypeIds = scopedTypes.map(type => type.id);
}
}
}
if (cancelled) {
return;
}
setDocumentTypeIds(effectiveTypeIds);
filterDocumentTypes(effectiveTypeIds, scopedTypes, normalizedModuleId);
await filterDocuments(effectiveTypeIds);
if (effectiveTypeIds && effectiveTypeIds.includes(1)) {
setIsContractType(true);
const contractType = scopedTypes.find(type => type.id === 1);
if (contractType) {
setFileType(contractType.id.toString());
setFileTypeError(null);
}
} else {
setIsContractType(false);
}
};
void bootstrapUploadScope().catch(error => {
console.error('初始化上传页入口模块作用域失败:', error);
});
} catch (error) {
console.error('获取 sessionStorage 中的 documentTypeIds 失败:', error);
}
return () => {
cancelled = true;
};
}, [loaderData]);
// 过滤文档类型列表
const filterDocumentTypes = (documentTypeIds: number[] | null, types: DocumentType[]) => {
const filterDocumentTypes = (documentTypeIds: number[] | null, types: DocumentType[], selectedModuleId?: number | null) => {
if (selectedModuleId) {
// 已通过 entry_module_id 从后端取过当前入口模块的文档类型,前端不再二次用旧缓存裁剪
setDocumentTypesState(types);
return;
}
if (!documentTypeIds || documentTypeIds.length === 0) {
// 如果没有特定的 documentTypeIds,使用原始数据
setDocumentTypesState(types);
@@ -439,7 +535,7 @@ export default function FilesUpload() {
setQueueFiles(loaderData.documents);
return;
}
const documents = response.data || [];
const documents = (response.data || []).filter(doc => documentTypeIds.includes(doc.type_id));
console.log('过滤文档列表成功:', documents);
setQueueFiles(documents);
@@ -591,11 +687,6 @@ export default function FilesUpload() {
try {
// console.log('开始检查队列状态,当前队列文件:', files);
// 直接从sessionStorage读取documentTypeIds,避免异步状态更新问题
const typeIdsStr = typeof window !== 'undefined' ? sessionStorage.getItem('documentTypeIds') : null;
const currentDocumentTypeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null;
// console.log('从sessionStorage读取的documentTypeIds:', currentDocumentTypeIds);
// 获取所有未完成的文档
const incompleteFiles = files.filter(file =>
file.status !== DocumentStatus.PROCESSED && file.id
@@ -609,7 +700,6 @@ export default function FilesUpload() {
let statusResponse;
// 如果是合同类型(ID=1),需要分类处理
// console.log('当前documentTypeIds:', currentDocumentTypeIds);
// if (currentDocumentTypeIds && currentDocumentTypeIds.includes(1)) {
// // 分类文档ID
// const mainDocumentIds: number[] = [];
@@ -666,6 +756,7 @@ export default function FilesUpload() {
// 处理文件选择
const handleFilesSelected = (files: FileList) => {
if (files.length > 0) {
clearUploadErrorDetails();
// 验证文件类型,支持PDF和Word文件
const validFiles: File[] = [];
let hasInvalidFiles = false;
@@ -708,6 +799,7 @@ export default function FilesUpload() {
// 处理文件类型变化
const handleFileTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
clearUploadErrorDetails();
// 确保只有选择了有效的文件类型才进行设置
if (value) {
// console.log('【调试-handleFileTypeChange】文件类型变更为:', value);
@@ -1102,6 +1194,7 @@ export default function FilesUpload() {
// 合同专用:首传即合并的上传链路
const startContractUpload = async (mainFiles: File[], attachmentFiles: File[], templateFiles: File[] = []) => {
try {
clearUploadErrorDetails();
// 只允许一个主文件
const mainFile = mainFiles[0];
@@ -1220,7 +1313,6 @@ export default function FilesUpload() {
await filterDocuments(documentTypeIds);
} catch (error) {
console.error('合同首传上传失败:', error);
messageService.error(`合同上传失败:${error instanceof Error ? error.message : '未知错误'}`);
if (uploadProgressIntervalRef.current) {
clearInterval(uploadProgressIntervalRef.current);
uploadProgressIntervalRef.current = null;
@@ -1230,6 +1322,7 @@ export default function FilesUpload() {
setContractTemplateFiles([]);
console.log('【合同上传失败】已清空合同模板文件缓存');
showFriendlyUploadError(error, { titlePrefix: '合同上传失败' });
resetUpload();
}
};
@@ -1237,6 +1330,7 @@ export default function FilesUpload() {
// 检查并准备上传
const checkAndPrepareUpload = async (mainFiles: File[], attachmentFiles: File[], templateFiles: File[] = []) => {
try {
clearUploadErrorDetails();
// console.log('【调试-checkAndPrepareUpload】开始检查并准备上传文件', {
// mainFilesCount: mainFiles.length,
// attachmentFilesCount: attachmentFiles.length,
@@ -1362,6 +1456,7 @@ export default function FilesUpload() {
// 开始上传文件
const startUpload = async (files: File[]) => {
try {
clearUploadErrorDetails();
console.log('【调试-startUpload】开始上传过程,文件数量:', files.length);
// 检查组件是否已卸载
@@ -1630,15 +1725,7 @@ export default function FilesUpload() {
clearInterval(uploadProgressIntervalRef.current);
}
// 显示错误提示
messageService.error(`文件上传失败:${error instanceof Error ? error.message : '未知错误'}`, {
title: '文件上传失败',
confirmText: '确定',
cancelText: '',
onConfirm: () => {
resetUpload();
}
});
showFriendlyUploadError(error, { titlePrefix: '文件上传失败' });
resetUpload();
// 抛出错误,让React错误边界捕获并显示
@@ -2330,6 +2417,50 @@ export default function FilesUpload() {
</div>
</div>
</Card>
{uploadErrorDetails && (
<Card className="mb-4 upload-error-card">
<div className="upload-error-banner">
<div className="upload-error-banner__header">
<div className="upload-error-banner__title-wrap">
<i className="ri-error-warning-line upload-error-banner__icon"></i>
<div>
<div className="upload-error-banner__title">{uploadErrorDetails.title}</div>
<p className="upload-error-banner__summary">{uploadErrorDetails.summary}</p>
</div>
</div>
<button
type="button"
className="upload-error-banner__dismiss"
onClick={clearUploadErrorDetails}
aria-label="关闭上传失败提示"
>
<i className="ri-close-line"></i>
</button>
</div>
<div className="upload-error-banner__content">
<div>
<div className="upload-error-banner__label"></div>
<ul className="upload-error-banner__list">
{uploadErrorDetails.detailLines.map((line, index) => (
<li key={`detail-${index}`}>{line}</li>
))}
</ul>
</div>
<div>
<div className="upload-error-banner__label"></div>
<ol className="upload-error-banner__list upload-error-banner__list--ordered">
{uploadErrorDetails.actionLines.map((line, index) => (
<li key={`action-${index}`}>{line}</li>
))}
</ol>
</div>
</div>
</div>
</Card>
)}
{/* 文件上传区域 */}
<Card className="mb-4">